Add visual monkey tests (Storybook and real app), run by CIs (#8982)

Adds `newIDE/visual-tests`: tests that manipulate the editor like a user would - in a real browser, with real clicks and real drag and drops and check after every single manipulation that nothing is broken.

Only show in developer changelog
This commit is contained in:
Florian Rival
2026-08-19 19:56:23 +02:00
committed by GitHub
parent 9eef4111a8
commit 4143fb8849
27 changed files with 5580 additions and 4 deletions
+381 -4
View File
@@ -9,11 +9,50 @@
# to avoid sharing secrets.
version: 2.1
parameters:
# Run *every* visual monkey test, even on a branch. Set it when triggering a
# pipeline (CircleCI UI or API) to check everything without waiting for the
# change to land on `master`.
run-all-visual-tests:
type: boolean
default: false
# Number of parallel containers the visual monkey tests are split across.
all-visual-tests-parallelism:
type: integer
default: 4
# Same, for a branch (which only runs the tests related to its changes).
changed-visual-tests-parallelism:
type: integer
default: 2
orbs:
aws-cli: circleci/[email protected]
macos: circleci/[email protected] # For Rosetta (see below)
node: circleci/[email protected] # For a recent npm version (see below)
win: circleci/[email protected]
commands:
# Used by the visual monkey tests jobs on a "changed" scope: stop the job
# (before anything expensive) when no test of the suite is related to what
# the branch changes. A change to the tests themselves relates every test.
halt-unless-a-visual-test-is-related-to-the-changes:
parameters:
scope:
type: enum
enum: ['all', 'changed']
suite:
type: enum
enum: ['storybook', 'editor']
steps:
- run:
name: Stop here if no test is related to the changes
command: |
if [ "<< parameters.scope >>" != "changed" ]; then exit 0; fi
git fetch --depth=50 origin master
cd newIDE/visual-tests
PUPPETEER_SKIP_DOWNLOAD=1 npm install --no-audit --no-fund --silent
if [ -z "$(node run.js --suite=<< parameters.suite >> --list-names --only-changed --base-ref=origin/master)" ]; then
echo "No visual monkey test of the << parameters.suite >> suite is related to these changes."
circleci-agent step halt
fi
jobs:
# Build the **entire** app for macOS (including the GDevelop.js library).
build-macos:
@@ -715,6 +754,268 @@ jobs:
path: ./cli-html5-export-artifacts
destination: cli-html5-export-artifacts
# Visual monkey tests: manipulate an editor in a real browser and check that
# nothing is broken (see newIDE/visual-tests/README.md).
#
# The "storybook" suite needs a built Storybook, which is built once here and
# shared with the containers running the tests.
build-storybook-for-visual-tests:
parameters:
scope:
description: >
"all" runs every test. "changed" only runs the tests related to what
the branch changes - and nothing at all if it changes nothing related.
type: enum
enum: ['all', 'changed']
default: 'all'
resource_class: large
docker:
- image: cimg/node:24.13.0
working_directory: ~/GDevelop
steps:
- checkout
- halt-unless-a-visual-test-is-related-to-the-changes:
scope: << parameters.scope >>
suite: 'storybook'
- restore_cache:
keys:
- gd-linux-nodejs-dependencies-v2-{{ checksum "newIDE/app/package.json" }}-{{ checksum "newIDE/electron-app/package.json" }}-{{ checksum "GDevelop.js/package.json" }}-{{ checksum "GDJS/package-lock.json" }}
# fallback to using the latest cache if no exact match is found
- gd-linux-nodejs-dependencies-v2-
- run:
name: Install GDevelop IDE dependencies
command: cd newIDE/app && npm install
- run:
name: Build Storybook
no_output_timeout: 20m
command: export NODE_OPTIONS="--max-old-space-size=7168" && cd newIDE/app && npm run build-storybook
- persist_to_workspace:
root: newIDE/app
paths:
- build-storybook
run-visual-monkey-tests-storybook-linux:
parameters:
scope:
type: enum
enum: ['all', 'changed']
default: 'all'
parallelism:
description: Number of containers the tests are split across.
type: integer
default: 1
parallelism: << parameters.parallelism >>
resource_class: large
docker:
- image: cimg/node:24.13.0
working_directory: ~/GDevelop
steps:
- checkout
- halt-unless-a-visual-test-is-related-to-the-changes:
scope: << parameters.scope >>
suite: 'storybook'
- attach_workspace:
at: newIDE/app
# The tests run in the Chrome that `npm install` downloads for Puppeteer
# (see newIDE/visual-tests/package.json), which needs these libraries.
- run:
name: Install the libraries Chrome needs
command: |
sudo apt-get update
sudo apt-get install -y \
libnss3 \
libasound2t64 \
libgbm1 \
libgtk-3-0 \
libxss1 \
libatk-bridge2.0-0 \
libdrm2 \
libxkbcommon0
- restore_cache:
keys:
- gd-visual-tests-dependencies-v1-{{ checksum "newIDE/visual-tests/package.json" }}
- run:
name: Install the visual tests dependencies (and the Chrome to run them in)
# The install of "Stop here if no test is related to the changes"
# skipped the Chrome download, and npm does not re-run the postinstall
# of an already installed Puppeteer: run it explicitly (it does
# nothing when Chrome is already in the restored cache).
command: |
cd newIDE/visual-tests
npm install --no-audit --no-fund
node node_modules/puppeteer/install.mjs
- save_cache:
paths:
- newIDE/visual-tests/node_modules
- ~/.cache/puppeteer
key: gd-visual-tests-dependencies-v1-{{ checksum "newIDE/visual-tests/package.json" }}
- run:
name: List the tests to run
command: |
cd newIDE/visual-tests
if [ "<< parameters.scope >>" = "changed" ]; then
node run.js --suite=storybook --list-names --only-changed --base-ref=origin/master > /tmp/tests.txt
else
node run.js --suite=storybook --list-names > /tmp/tests.txt
fi
echo "Tests to run:"
cat /tmp/tests.txt
- run:
name: Split the tests across the parallel containers
# Balanced using the durations recorded by `store_test_results` on the
# previous runs (falls back to an even split the first time).
command: |
if [ -s /tmp/tests.txt ]; then
circleci tests split --split-by=timings --timings-type=filename \
< /tmp/tests.txt > /tmp/tests-for-this-container.txt
else
: > /tmp/tests-for-this-container.txt
fi
echo "Tests for this container:"
cat /tmp/tests-for-this-container.txt
- run:
name: Run the visual monkey tests on Storybook
no_output_timeout: 30m
# The failure is reported by the last step of the job, so that the
# results and the screenshots of a failing run are uploaded first.
command: |
set +e
cd newIDE/visual-tests
node run.js --suite=storybook \
--tests-file=/tmp/tests-for-this-container.txt \
--artifacts-dir=/tmp/visual-tests-artifacts \
--junit-path=/tmp/visual-tests-results/results.xml
echo $? > /tmp/visual-tests-exit-code
- store_test_results:
path: /tmp/visual-tests-results
- store_artifacts:
path: /tmp/visual-tests-artifacts
destination: visual-tests
- run:
name: Report whether the visual monkey tests passed
command: exit "$(cat /tmp/visual-tests-exit-code)"
# Visual monkey tests, "editor" suite: smoke tests using a real, packaged
# version of the app (like run-cli-html5-export-linux does) opening a real
# example game.
#
# Without a local build (so on a branch), the tests run against the latest
# portable build of `master` on S3: they then exercise the tests of the
# branch, not its changes to the app itself - those are only tested by the
# jobs following build-linux, on the branches building the app.
run-visual-monkey-tests-editor-linux:
parameters:
scope:
description: >
"all" runs every test. "changed" only runs the tests related to what
the branch changes - and nothing at all if it changes nothing related.
type: enum
enum: ['all', 'changed']
default: 'all'
use-local-build:
description: When true, attach the workspace produced by build-linux and use its portable zip instead of downloading the latest one from S3.
type: boolean
default: false
resource_class: medium
docker:
- image: cimg/node:24.13.0
working_directory: ~/GDevelop
steps:
- checkout
- halt-unless-a-visual-test-is-related-to-the-changes:
scope: << parameters.scope >>
suite: 'editor'
- when:
condition: << parameters.use-local-build >>
steps:
- attach_workspace:
at: ~/GDevelop/build-linux-workspace
# Runtime libraries required by Electron (headless still needs nss /
# alsa to even start up).
- run:
name: Install Electron runtime dependencies
command: |
sudo apt-get update
sudo apt-get install -y \
libnss3 \
libasound2t64 \
libgbm1 \
libgtk-3-0 \
libxss1 \
libatk-bridge2.0-0 \
libdrm2 \
libxkbcommon0 \
xvfb
- run:
name: Install the visual tests dependencies
command: cd newIDE/visual-tests && npm install --no-audit --no-fund
- run:
name: Run the visual monkey tests on the real editor
# The editor is a GUI app: it needs a display, even to run headless.
# The failure is reported by the last step of the job, so that the
# results and the screenshots of a failing run are uploaded first.
no_output_timeout: 30m
command: |
set +e
cd newIDE/visual-tests
if [ "<< parameters.use-local-build >>" = "true" ]; then
ZIP_PATH=$(ls ~/GDevelop/build-linux-workspace/gdevelop-*.zip | grep -v -- '-arm64' | head -1)
if [ -z "$ZIP_PATH" ]; then
echo "No portable zip found in the build-linux workspace." >&2
exit 1
fi
echo "Using the locally built portable zip: $ZIP_PATH"
EXTRA_ARGS="--gdevelop-zip=$ZIP_PATH"
else
echo "No local build available; the latest portable zip of master will be downloaded."
EXTRA_ARGS="--gdevelop-branch=master"
fi
if [ "<< parameters.scope >>" = "changed" ]; then
EXTRA_ARGS="$EXTRA_ARGS --only-changed --base-ref=origin/master"
fi
xvfb-run -a --server-args="-screen 0 1500x1000x24" \
node run.js --suite=editor $EXTRA_ARGS \
--artifacts-dir=/tmp/visual-tests-artifacts \
--junit-path=/tmp/visual-tests-results/results.xml
echo $? > /tmp/visual-tests-exit-code
- store_test_results:
path: /tmp/visual-tests-results
- store_artifacts:
path: /tmp/visual-tests-artifacts
destination: visual-tests
- run:
name: Report whether the visual monkey tests passed
command: exit "$(cat /tmp/visual-tests-exit-code)"
workflows:
gdevelop_js-wasm-extra-checks:
jobs:
@@ -766,6 +1067,19 @@ workflows:
- master
- stable
- /experimental-build.*/
# Right after build-linux, run the visual monkey tests of the real
# editor against the portable zip it just produced.
- run-visual-monkey-tests-editor-linux:
name: visual-monkey-tests-editor-linux
use-local-build: true
requires:
- build-linux
filters:
branches:
only:
- master
- stable
- /experimental-build.*/
- build-windows:
requires:
- build-gdevelop_js-wasm-only
@@ -786,10 +1100,10 @@ workflows:
# - stable
# - /experimental-build.*/
# Daily end-to-end test of the GDevelop CLI HTML5 export. Runs every day
# at 16:00 UTC (4pm UTC) against the latest portable build uploaded to S3
# for master.
daily-cli-html5-export-linux:
# Daily end-to-end tests running against the latest portable build uploaded
# to S3 for master: the CLI HTML5 export, and the visual monkey tests
# manipulating the editor. Runs every day at 16:00 UTC (4pm UTC).
daily-end-to-end-tests:
triggers:
- schedule:
cron: "0 16 * * *"
@@ -799,3 +1113,66 @@ workflows:
- master
jobs:
- run-cli-html5-export-linux
- run-visual-monkey-tests-editor-linux
# On `master`/`stable` (or when explicitly asked for): manipulate the editors
# in a real browser and check that nothing is broken, with every test.
# (`master`, `stable` and the experimental builds also run the editor suite
# against the build build-linux just produced, in the main workflow.)
all-visual-tests:
when:
or:
- equal: [master, << pipeline.git.branch >>]
- equal: [stable, << pipeline.git.branch >>]
- << pipeline.parameters.run-all-visual-tests >>
jobs:
- build-storybook-for-visual-tests:
name: visual-tests-storybook-build-all
scope: 'all'
- run-visual-monkey-tests-storybook-linux:
name: visual-tests-storybook-all
scope: 'all'
parallelism: << pipeline.parameters.all-visual-tests-parallelism >>
requires:
- visual-tests-storybook-build-all
# On a branch forced with `run-all-visual-tests`: every test of the
# editor suite too, against the latest portable build of `master`.
- run-visual-monkey-tests-editor-linux:
name: visual-tests-editor-all
scope: 'all'
filters:
branches:
ignore:
- master
- stable
- /experimental-build.*/
# On a branch / Pull Request: only run the tests related to what it changes
# (nothing at all when it changes nothing related to them). The editor suite
# runs against the latest portable build of `master` (a branch builds no
# app), so it exercises the tests of the branch, not its changes to the app.
changed-visual-tests:
when:
and:
- not:
equal: [master, << pipeline.git.branch >>]
- not:
equal: [stable, << pipeline.git.branch >>]
- not: << pipeline.parameters.run-all-visual-tests >>
jobs:
- build-storybook-for-visual-tests:
name: visual-tests-storybook-build-changed
scope: 'changed'
- run-visual-monkey-tests-storybook-linux:
name: visual-tests-storybook-changed
scope: 'changed'
parallelism: << pipeline.parameters.changed-visual-tests-parallelism >>
requires:
- visual-tests-storybook-build-changed
- run-visual-monkey-tests-editor-linux:
name: visual-tests-editor-changed
scope: 'changed'
filters:
branches:
ignore:
- /experimental-build.*/
+3
View File
@@ -0,0 +1,3 @@
node_modules/
artifacts/
work/
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "es5"
}
+141
View File
@@ -0,0 +1,141 @@
# Visual monkey tests
These tests manipulate the editor like a user would - in a real browser, with
real clicks and real drag and drops - and check after **every single
manipulation** that nothing is broken.
They catch the bugs that unit tests can't see, because they only appear when
everything runs together: something displayed that is not what the project
contains, a manipulation acting on the wrong thing, or an error taking the whole
editor down. A few dozen random manipulations find these in seconds.
## Two suites
| | `storybook-tests/` | `editor-tests/` |
| --- | --- | --- |
| Runs on | The Storybook stories of one editor | A real packaged app opening a real game |
| Checks | What is displayed vs. what the project contains | The editor is there and nothing throws |
| For | Precise tests of one editor | Smoke tests: "nothing is badly broken" |
The Storybook suite can check much more, because a story can expose the object
being edited. The editor suite runs the real thing, but can only look at the
page - and anything going through a menu (native menus on the desktop app), a
file picker or an external editor window is left to the Storybook suite.
## Running them
```bash
cd newIDE/visual-tests
npm install # also downloads the Chrome to run in
node run.js --list
node run.js --suite=storybook # builds Storybook if needed, then runs
node run.js --suite=editor # downloads the latest portable build
node run.js --suite=all
```
| Option | |
| --- | --- |
| `--test=<part of a name>` | Only run the tests whose name contains this |
| `--headful` | Show the browser (to see what the test does) |
| `--verbose` | Log every manipulation of the random sessions |
| `--storybook-url=<url>` | Use a Storybook already running instead of building |
| `--rebuild-storybook` | Rebuild Storybook even if already built |
| `--gdevelop-zip=<path>` | Use this portable build instead of downloading one |
| `--gdevelop-branch=<branch>` | Download the latest portable build of this branch |
| `--editor-monkey-steps=<n>` | Manipulations of a random session on the real app |
| `--artifacts-dir=<path>` | Where screenshots and logs go (default: `artifacts`) |
| `--chrome-path=<path>` | The Chrome to run in |
| `--only-changed --base-ref=<ref>` | Only run the tests related to what changed |
| `--list-names`, `--tests-file=<path>` | List / run exactly these tests (used to split them across the CI containers) |
| `--junit-path=<path>` | Write the results as JUnit |
On a branch, the CI only runs the tests related to what it changes (and builds
nothing if there are none): each helper declares the sources its tests watch, in
its `paths`, and changing the tests themselves relates every test. Everything
runs on `master` - a change anywhere else can break an editor too - and
`run-all-visual-tests` on a pipeline forces that on a branch.
Both suites work this way, with one difference: on a branch, the editor suite
runs against the latest portable build of `master` (a branch builds no app), so
it exercises the tests of the branch, but not its changes to the app itself -
those are tested by the same suite on `master`, right after `build-linux`.
A problem these tests find but are not here to guard can be listed in
`lib/KnownIssues.js`: it is then reported loudly without failing the run, so
that a branch is not blocked by something it did not introduce.
While writing a test, `--storybook-url=http://localhost:9009 --headful
--test=<name>` (with `npm run storybook` running) gives the fastest feedback.
## Writing a test
A test file exports an array of tests. A Storybook test is a list of
manipulations, or a random session ("monkey") whose seeds make it reproducible:
```js
const spriteEditor = require('../helpers/SpriteEditor');
module.exports = [
{
name: 'sprite-editor/delete-animations',
helper: spriteEditor,
story: 'objecteditor-spriteeditormanipulations--manipulations',
steps: [
['selectFrames', { row: 1, frames: [0, 1] }],
['deleteAnimation', { row: 0 }],
],
},
{
name: 'sprite-editor/monkey',
helper: spriteEditor,
story: 'objecteditor-spriteeditormanipulations--manipulations',
monkey: { seeds: [1, 2], steps: 60 },
},
];
```
An editor test opens an example game in the real app and drives it, using the
same manipulations:
```js
{
name: 'editor/add-a-behavior-from-the-store',
example: 'platformer', // sparse-cloned from GDevelop-examples
helpers: [spriteEditor], // page helpers to install (optional)
run: async ({ page, reporter, screenshot, runSteps, runMonkey }) => { ... },
}
```
## Testing another part of the app
Everything generic is in `lib/`: driving a page (`PageHelpers.js`,
`PageDriver.js`), running and checking manipulations (`Runner.js`), the two
suites and the reporting. None of it knows about any particular editor.
An editor is described by a **helper** in `helpers/`, which is the only place
knowing about it:
| | |
| --- | --- |
| `installPageHelpers` | Runs in the page: finds the controls of the editor and reads what it displays. Registers a resolver so its targets (a row, a frame...) can be used like the generic ones (`{ button: 'Apply' }`, `{ selector: '#id' }`, `{ menuItem: '...' }`, `{ tab: 'Behaviors' }`). |
| `actions` | The manipulations, with how the monkey may pick them (`pick`) and what must be true afterwards (`expect`, checked by `checkExpectation`) |
| `describe` / `check` | What the editor displays, and whether it matches the project |
| `snapshot` (optional) | What the manipulations may change, to tell a manipulation that did nothing from one that worked (`describe` is used otherwise), with `describeEffect` to log what changed |
| `stepChecks` (optional) | The invariants of the editor: each one is checked after every manipulation flagged with its name (e.g. `keepsTheFrames` in the Sprite editor) |
| `summarize` (optional) | One line describing what is displayed, logged when a story is opened |
| `paths` | The sources its tests watch: they only run when one of these changed |
`helpers/SpriteEditor.js` is the complete example. `helpers/ObjectsList.js`,
`helpers/BehaviorsEditor.js` and `helpers/PropertiesPanel.js` are smaller ones,
used by the editor tests to reach and manipulate other parts of the app.
## What is checked after every manipulation
- The page threw nothing (an uncaught error takes the editor down).
- The editor is still displayed.
- What it displays matches the project (when the helper can read it).
- The manipulation actually changed something, so a test cannot pass without
having exercised anything.
- Manipulations with a precise outcome (`expect`) get it checked, and the
invariants declared by the helper (`stepChecks`) hold.
@@ -0,0 +1,135 @@
// @ts-check
/**
* Adding a behavior coming from an extension of the store to an object of a
* real game, in the real app - through the two ways the editor offers it: the
* "Behaviors" tab of the object editor dialog, and the properties panel of the
* Scene editor.
*/
const objectsList = require('../helpers/ObjectsList');
const behaviorsEditor = require('../helpers/BehaviorsEditor');
const propertiesPanel = require('../helpers/PropertiesPanel');
const FIRE_BULLETS = {
search: 'Fire bullet',
behaviorType: 'FireBullet::FireBullet',
name: 'Fire bullets',
};
const checkANewBehavior = (behaviorsBefore, behaviorsAfter) =>
behaviorsAfter.some(behavior => !behaviorsBefore.includes(behavior))
? null
: 'the behavior was not added to the object (the list is unchanged)';
module.exports = [
{
name: 'editor/add-a-behavior-from-the-store',
description:
'In the object editor dialog: search "Fire bullet" in the store, add ' +
'the "Fire bullets" behavior to an object (its extension is downloaded ' +
'and installed), and check it is there.',
example: 'platformer',
helpers: [objectsList, behaviorsEditor],
run: async ({ page, reporter, screenshot }) => {
const failures = [];
let performed = 0;
const fail = failure => {
reporter.log(` ❌ ${failure}`);
return { failures: [...failures, failure], performed, skipped: 0 };
};
const objectNames = await objectsList.waitForTheScene(page);
reporter.log(` The scene is opened, with: ${objectNames.join(', ')}.`);
if (!(await objectsList.openObjectEditor(page, 'Player')))
return fail('the editor of the object "Player" did not open');
performed++;
if (!(await objectsList.openObjectEditorTab(page, 'Behaviors')))
return fail('the "Behaviors" tab could not be opened');
performed++;
const behaviorsBefore = await behaviorsEditor.listBehaviors(page);
reporter.log(
` ✓ the behaviors of "Player": ${behaviorsBefore.join(', ') ||
'(none)'}`
);
await screenshot('behaviors-before');
const problem = await behaviorsEditor.addBehavior(page, FIRE_BULLETS);
if (problem) {
await screenshot('failed-to-add-the-behavior');
return fail(problem);
}
performed++;
const behaviorsAfter = await behaviorsEditor.listBehaviors(page);
reporter.log(
` ✓ the behaviors of "Player" are now: ${behaviorsAfter.join(', ')}`
);
await screenshot('behaviors-after');
const missing = checkANewBehavior(behaviorsBefore, behaviorsAfter);
if (missing) failures.push(missing);
if (!(await objectsList.closeObjectEditor(page)))
failures.push('the object editor did not close');
else performed++;
return { failures, performed, skipped: 0 };
},
},
{
name: 'editor/add-a-behavior-from-the-properties-panel',
description:
'In the properties panel of the Scene editor (without opening the ' +
'object editor dialog): select an object, add the "Fire bullets" ' +
'behavior of the store from its "Behaviors" section, and check it is ' +
'there.',
example: 'platformer',
helpers: [objectsList, propertiesPanel, behaviorsEditor],
run: async ({ page, reporter, screenshot }) => {
const failures = [];
let performed = 0;
const fail = failure => {
reporter.log(` ❌ ${failure}`);
return { failures: [...failures, failure], performed, skipped: 0 };
};
const objectNames = await objectsList.waitForTheScene(page);
reporter.log(` The scene is opened, with: ${objectNames.join(', ')}.`);
if (!(await propertiesPanel.selectObject(page, 'Player')))
return fail(
'the properties panel did not show the object "Player" when ' +
'selected in the objects list'
);
performed++;
const behaviorsBefore = await propertiesPanel.listBehaviors(page);
reporter.log(
` ✓ the panel shows the behaviors of "Player": ` +
`${behaviorsBefore.join(', ') || '(none)'}`
);
await screenshot('panel-before');
const problem =
(await propertiesPanel.openAddBehaviorDialog(page)) ||
(await behaviorsEditor.chooseBehaviorInDialog(page, FIRE_BULLETS));
if (problem) {
await screenshot('failed-to-add-the-behavior');
return fail(problem);
}
performed++;
const behaviorsAfter = await propertiesPanel.listBehaviors(page);
reporter.log(
` ✓ the panel now shows the behaviors: ${behaviorsAfter.join(', ')}`
);
await screenshot('panel-after');
const missing = checkANewBehavior(behaviorsBefore, behaviorsAfter);
if (missing) failures.push(missing);
return { failures, performed, skipped: 0 };
},
},
];
@@ -0,0 +1,132 @@
// @ts-check
/**
* Smoke tests of the Sprite object editor in the real app, opening a real
* example game. They are much less precise than the Storybook tests: the
* animations of the edited object can't be read from the app, so they only
* check that nothing is badly broken.
*
* Anything going through a menu is not tested here: menus are native menus on
* the desktop app, which can't be driven. Same for anything opening a file
* picker or an external editor window.
*/
const spriteEditor = require('../helpers/SpriteEditor');
const objectsList = require('../helpers/ObjectsList');
module.exports = [
{
name: 'editor/opens-every-object-editor',
description:
'The editor of every object of the scene of an example game can be ' +
'opened and closed.',
example: 'platformer',
helpers: [spriteEditor, objectsList],
run: async ({ page, pageErrors, reporter, screenshot }) => {
const failures = [];
let performed = 0;
const objectNames = await objectsList.waitForTheScene(page);
reporter.log(` The scene is opened, with: ${objectNames.join(', ')}.`);
await screenshot('scene');
if (!objectNames.length)
return { failures: ['no object in the scene'], performed, skipped: 0 };
for (const objectName of objectNames) {
if (!(await objectsList.openObjectEditor(page, objectName))) {
failures.push(`the editor of "${objectName}" did not open`);
break;
}
const state = await spriteEditor.describe(page);
const framesCount = state.rows.reduce(
(total, row) => total + row.frames.length,
0
);
reporter.log(
` ✓ the editor of "${objectName}" is opened` +
(state.rows.length
? ` (${state.rows.length} animations, ${framesCount} frames)`
: ' (not an animated object)')
);
performed++;
if (!(await objectsList.closeObjectEditor(page))) {
failures.push(`the editor of "${objectName}" did not close`);
break;
}
if (pageErrors.length) break;
}
await screenshot('after-opening-the-object-editors');
return { failures, performed, skipped: 0 };
},
},
{
name: 'editor/sprite-editor-manipulations',
description:
'Manipulate the animations of a real sprite object: a smoke test of what ' +
'the Storybook tests cover precisely.',
example: 'platformer',
helpers: [spriteEditor, objectsList],
run: async ({ page, reporter, screenshot, runSteps }) => {
await objectsList.waitForTheScene(page);
if (!(await objectsList.openObjectEditor(page, 'Player')))
return {
failures: ['the editor of the object "Player" did not open'],
performed: 0,
skipped: 0,
};
const state = await spriteEditor.describe(page);
reporter.log(
` The object "Player" has ${state.rows.length} animations displayed.`
);
await screenshot('sprite-editor');
const result = await runSteps({
helper: spriteEditor,
steps: [
['selectFrames', { row: 0, frames: [0] }],
['setTimeBetweenFrames', { row: 0, value: '0.2' }],
['toggleLoop', { row: 0 }],
['openPreview', { row: 0 }],
['addAnimation'],
['renameAnimation', { row: 0, name: 'Walking to the right' }],
// Upwards, so that the drop moves it (dropping on the row just
// after would put it back where it was) - and onto the row just
// before, so that both rows fit on the small window of the app.
['dragAnimation', { from: 1, to: 0 }],
['openPointsEditor'],
['openCollisionMasksEditor'],
['deleteAnimation', { row: 0 }],
['scrollList', { delta: 400 }],
['scrollList', { delta: -400 }],
],
});
await screenshot('after-the-manipulations');
if (!(await objectsList.closeObjectEditor(page)))
result.failures.push('the object editor did not close');
return result;
},
},
{
name: 'editor/sprite-editor-monkey',
description:
'Random manipulations of the animations of a real sprite object.',
example: 'platformer',
helpers: [spriteEditor, objectsList],
run: async ({ page, reporter, runMonkey }) => {
await objectsList.waitForTheScene(page);
if (!(await objectsList.openObjectEditor(page, 'Player')))
return {
failures: ['the editor of the object "Player" did not open'],
performed: 0,
skipped: 0,
};
const actionNames = spriteEditor.getRealEditorActionNames();
reporter.log(` Manipulations used: ${actionNames.join(', ')}.`);
return await runMonkey({ helper: spriteEditor, actionNames });
},
},
];
@@ -0,0 +1,108 @@
// @ts-check
/**
* The behaviors of an object: the list shown in the "Behaviors" tab of the
* object editor, and the dialog to add one - including the behaviors coming
* from an extension of the store, which is then downloaded and installed.
*/
const {
click,
exists,
typeInInput,
waitFor,
wait,
} = require('../lib/PageDriver');
const NEW_BEHAVIOR_DIALOG = { selector: '#new-behavior-dialog' };
const ADD_BEHAVIOR_BUTTON = { selector: '#add-behavior-button' };
const SEARCH_FIELD = { selector: '#extension-search-bar' };
/** The item of the store proposing a behavior, by its type. */
const storeItemOf = behaviorType => ({
selector: `#behavior-item-${behaviorType.replace(/:/g, '-')}`,
});
/** The names of the behaviors of the edited object. */
const listBehaviors = page =>
page.evaluate(() =>
Array.from(document.querySelectorAll('[id$="-name-text-field"]')).map(
element =>
element.id.replace(/-name-text-field$/, '').replace(/^behavior-/, '')
)
);
/** The behaviors the store proposes for the current search. */
const listProposedBehaviors = page =>
page.evaluate(() =>
Array.from(document.querySelectorAll('[id^="behavior-item-"]')).map(
element => (element.textContent || '').trim().slice(0, 60)
)
);
/**
* In the already opened "add a behavior" dialog: search a behavior in the
* store and add it to the edited object. When it comes from an extension, the
* extension is downloaded and installed. Returns null, or what went wrong.
*/
const chooseBehaviorInDialog = async (page, { search, behaviorType, name }) => {
if (!(await typeInInput(page, SEARCH_FIELD, search)))
return 'there is no search field in the dialog';
// Searching waits for the extensions of the store to be fetched.
const storeItem = storeItemOf(behaviorType);
if (!(await waitFor(page, storeItem, 30000)))
return `"${search}" did not propose the behavior ${behaviorType}`;
if (name) {
const itemText = await page.evaluate(
selector => (document.querySelector(selector).textContent || '').trim(),
storeItem.selector
);
if (!itemText.startsWith(name))
return `the behavior ${behaviorType} is named "${itemText.slice(
0,
40
)}" instead of "${name}"`;
}
if (!(await click(page, storeItem)))
return `the behavior ${behaviorType} could not be chosen`;
// Installing the extension of the behavior takes a moment.
const startedAt = Date.now();
while (Date.now() - startedAt < 90000) {
if (!(await exists(page, NEW_BEHAVIOR_DIALOG))) break;
await wait(1000);
}
if (await exists(page, NEW_BEHAVIOR_DIALOG))
return `the behavior ${behaviorType} was not added (the dialog is still opened)`;
await wait(2000);
return null;
};
/**
* Add a behavior of the store from the "Behaviors" tab of the object editor.
* Returns null, or what went wrong.
*/
const addBehavior = async (page, behaviorToChoose) => {
if (!(await click(page, ADD_BEHAVIOR_BUTTON)))
return 'there is no "Add a behavior" button';
if (!(await waitFor(page, NEW_BEHAVIOR_DIALOG, 20000)))
return 'the dialog to add a behavior did not open';
await wait(1500);
return await chooseBehaviorInDialog(page, behaviorToChoose);
};
module.exports = {
name: 'behaviors-editor',
paths: [
'newIDE/app/src/BehaviorsEditor/',
'newIDE/app/src/AssetStore/BehaviorStore/',
],
NEW_BEHAVIOR_DIALOG,
storeItemOf,
listBehaviors,
listProposedBehaviors,
chooseBehaviorInDialog,
addBehavior,
};
@@ -0,0 +1,58 @@
// @ts-check
/**
* The objects list of a scene, and the dialog editing one object. Used by the
* tests running on the real app to reach the editor they want to manipulate.
*/
const { click, exists, waitFor, wait } = require('../lib/PageDriver');
const OBJECT_EDITOR_DIALOG = { selector: '#object-editor-dialog' };
/** Wait for the scene to be opened and return the names of its objects. */
const waitForTheScene = async (page, timeoutInMs = 180000) => {
await page.waitForSelector('[data-object-name]', { timeout: timeoutInMs });
await wait(2000);
return await page.evaluate(() =>
Array.from(document.querySelectorAll('[data-object-name]')).map(element =>
element.getAttribute('data-object-name')
)
);
};
/** Open the editor of an object by double clicking it in the objects list. */
const openObjectEditor = async (page, objectName) => {
const row = await page.$(`[data-object-name="${objectName}"]`);
if (!row) return false;
await row.click({ clickCount: 2, delay: 80 });
if (!(await waitFor(page, OBJECT_EDITOR_DIALOG, 20000))) return false;
await wait(2500);
return true;
};
const closeObjectEditor = async page => {
if (!(await click(page, { selector: '#apply-button' })))
await page.keyboard.press('Escape');
await wait(2000);
return !(await exists(page, OBJECT_EDITOR_DIALOG));
};
/** Show one of the tabs of the object editor ("Behaviors", "Variables"...). */
const openObjectEditorTab = async (page, tabName) => {
if (!(await click(page, { tab: tabName }))) return false;
await wait(1500);
return true;
};
module.exports = {
name: 'objects-list',
paths: [
'newIDE/app/src/ObjectsList/',
'newIDE/app/src/ObjectEditor/ObjectEditorDialog.js',
],
OBJECT_EDITOR_DIALOG,
waitForTheScene,
openObjectEditor,
closeObjectEditor,
openObjectEditorTab,
};
@@ -0,0 +1,124 @@
// @ts-check
/**
* The properties panel of the Scene editor (the "side bar"): it shows the
* properties, behaviors and variables of the object selected in the objects
* list, without opening the object editor dialog.
*/
const { click, waitFor, wait } = require('../lib/PageDriver');
const { NEW_BEHAVIOR_DIALOG } = require('./BehaviorsEditor');
const PANEL = { selector: '#object-properties-editor' };
/**
* Finds the sections of the panel ("Properties", "Behaviors"...) and what they
* contain. Targets are `{ propertiesPanel: { section, kind: 'add' } }`: the
* button adding to a section, in its title bar.
*/
const installPropertiesPanelPageHelpers = function() {
const { isLeaf, textOf, addTargetResolver } = window.gdVisualTests;
const getPanel = () => document.querySelector('#object-properties-editor');
// The title bar of a section: its title, and next to it the icon buttons to
// open the full editor and to add to the section (the last one).
const getSectionTitleBar = sectionTitle => {
const panel = getPanel();
if (!panel) return null;
const title = Array.from(panel.querySelectorAll('*')).find(
element => isLeaf(element) && textOf(element) === sectionTitle
);
let titleBar = title ? title.parentElement : null;
while (titleBar && titleBar.querySelectorAll('button').length < 2)
titleBar = titleBar.parentElement;
return titleBar;
};
addTargetResolver('propertiesPanel', target => {
const titleBar = getSectionTitleBar(target.section);
if (!titleBar) return null;
if (target.kind === 'add') {
const buttons = Array.from(titleBar.querySelectorAll('button'));
return buttons[buttons.length - 1] || null;
}
return null;
});
/**
* The names of the behaviors of the "Behaviors" section: each one is a
* sub-panel whose title bar has a "remove behavior" button. The name is the
* first text next to that button, walking up to the title bar (no CSS
* classes: the production build minifies them all away).
*/
const listBehaviors = () => {
const panel = getPanel();
if (!panel) return [];
return Array.from(panel.querySelectorAll('#remove-behavior'))
.map(removeButton => {
for (
let ancestor = removeButton.parentElement;
ancestor && panel.contains(ancestor);
ancestor = ancestor.parentElement
) {
const title = Array.from(ancestor.querySelectorAll('*')).find(
element =>
isLeaf(element) &&
textOf(element) &&
!removeButton.contains(element)
);
if (title) return textOf(title);
}
return null;
})
.filter(Boolean);
};
window.gdVisualTests.propertiesPanel = { listBehaviors };
};
/**
* Select an object in the objects list of the scene, so that the panel shows
* its properties.
*/
const selectObject = async (page, objectName) => {
const row = await page.$(`[data-object-name="${objectName}"]`);
if (!row) return false;
await row.click();
if (!(await waitFor(page, PANEL, 15000))) return false;
await wait(1000);
return true;
};
/** The names of the behaviors shown in the "Behaviors" section. */
const listBehaviors = page =>
page.evaluate(() => window.gdVisualTests.propertiesPanel.listBehaviors());
/**
* Open the dialog to add a behavior from the "Behaviors" section of the panel
* (the same dialog as in the object editor: see `chooseBehaviorInDialog` of
* the BehaviorsEditor helper). Returns null, or what went wrong.
*/
const openAddBehaviorDialog = async page => {
const addButton = { propertiesPanel: { section: 'Behaviors', kind: 'add' } };
if (!(await click(page, addButton)))
return 'there is no button to add a behavior on the "Behaviors" section';
if (!(await waitFor(page, NEW_BEHAVIOR_DIALOG, 20000)))
return 'the dialog to add a behavior did not open';
await wait(1500);
return null;
};
module.exports = {
name: 'properties-panel',
paths: [
'newIDE/app/src/ObjectEditor/CompactObjectPropertiesEditor/',
'newIDE/app/src/CompactPropertiesEditor/',
'newIDE/app/src/SceneEditor/InstanceOrObjectPropertiesEditorContainer.js',
],
PANEL,
installPageHelpers: installPropertiesPanelPageHelpers,
selectObject,
listBehaviors,
openAddBehaviorDialog,
};
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
// @ts-check
const { spawnSync } = require('child_process');
const path = require('path');
const repositoryRoot = path.resolve(__dirname, '..', '..', '..');
/** The files changed compared to a reference, or null if it can't be told. */
const getChangedFiles = baseRef => {
const result = spawnSync(
'git',
['diff', '--name-only', `${baseRef}...HEAD`],
{ cwd: repositoryRoot, encoding: 'utf8' }
);
if (result.status !== 0) return null;
return result.stdout
.split('\n')
.map(line => line.trim())
.filter(Boolean);
};
/**
* The tests to run for these changes: the ones watching a path that changed.
* A test watching nothing always runs, and everything runs when the tests
* themselves changed or when the changes can't be told.
*/
const filterTestsForChangedFiles = ({ tests, changedFiles, log }) => {
if (!changedFiles) {
if (log) log('The changed files could not be listed: running every test.');
return tests;
}
if (!changedFiles.length) {
if (log) log('No file changed: running every test.');
return tests;
}
if (changedFiles.some(file => file.startsWith('newIDE/visual-tests/'))) {
if (log) log('The tests themselves changed: running every test.');
return tests;
}
return tests.filter(test => {
const watchedPaths = getWatchedPaths(test);
if (!watchedPaths.length) return true;
return changedFiles.some(file =>
watchedPaths.some(watchedPath => file.startsWith(watchedPath))
);
});
};
/** The paths a test watches: its own, or the ones of the helpers it uses. */
const getWatchedPaths = test => {
if (test.paths) return test.paths;
const helpers = test.helpers || (test.helper ? [test.helper] : []);
return helpers.reduce((all, helper) => all.concat(helper.paths || []), []);
};
module.exports = {
getChangedFiles,
filterTestsForChangedFiles,
getWatchedPaths,
};
+36
View File
@@ -0,0 +1,36 @@
// @ts-check
const fs = require('fs');
const puppeteer = require('puppeteer');
/**
* The Chrome to run the tests in: the one asked for, one installed on the
* machine, or the one `npm install` downloaded for Puppeteer.
*/
const findChrome = givenPath => {
const candidates = [
givenPath,
process.env.CHROME_PATH,
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/local/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
].filter(Boolean);
const existing = candidates.find(candidate => fs.existsSync(candidate));
if (existing) return existing;
try {
const downloadedPath = puppeteer.executablePath();
if (fs.existsSync(downloadedPath)) return downloadedPath;
} catch (error) {
// No browser downloaded for Puppeteer.
}
throw new Error(
'No Chrome found to run the tests in. Run `npm install` in ' +
'newIDE/visual-tests (it downloads one) or pass --chrome-path=...'
);
};
module.exports = { findChrome };
+52
View File
@@ -0,0 +1,52 @@
// @ts-check
const fs = require('fs');
const https = require('https');
/**
* Download a file, following redirects. Kept dependency free so that this
* folder can be used with only `npm install` in it.
*/
const downloadFile = (url, destinationPath, redirectsLeft = 5) =>
new Promise((resolve, reject) => {
https
.get(url, response => {
const { statusCode, headers } = response;
if (
statusCode &&
statusCode >= 300 &&
statusCode < 400 &&
headers.location
) {
response.resume();
if (redirectsLeft === 0) {
reject(new Error(`Too many redirects for ${url}`));
return;
}
downloadFile(
headers.location,
destinationPath,
redirectsLeft - 1
).then(resolve, reject);
return;
}
if (statusCode !== 200) {
response.resume();
reject(new Error(`Got status ${String(statusCode)} for ${url}`));
return;
}
const file = fs.createWriteStream(destinationPath);
response.pipe(file);
file.on('finish', () => file.close(() => resolve()));
file.on('error', reject);
})
.on('error', reject);
});
const formatBytes = size => {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KiB`;
return `${(size / 1024 / 1024).toFixed(1)} MiB`;
};
module.exports = { downloadFile, formatBytes };
+115
View File
@@ -0,0 +1,115 @@
// @ts-check
const { installPageHelpers } = require('./PageHelpers');
const {
getPortableBuild,
getExampleProject,
launchEditor,
} = require('./RealEditor');
const { runSteps, runMonkey } = require('./Runner');
const { wait, watchPageErrors } = require('./PageDriver');
const DEBUGGING_PORT = 9333;
/**
* Run the tests using a real, packaged version of the editor: these are smoke
* tests, only there to catch something badly broken in the real app.
*/
const runEditorSuite = async ({ tests, options, reporter }) => {
const { binaryPath, version } = await getPortableBuild({
zipPath: options.gdevelopZipPath,
branch: options.gdevelopBranch,
workDirectory: options.workDirectory,
log: reporter.log,
});
for (const test of tests) {
reporter.log('');
reporter.log(`TEST ${test.name}`);
if (test.description) reporter.log(` ${test.description}`);
const projectPath = test.example
? getExampleProject({
slug: test.example,
workDirectory: options.workDirectory,
log: reporter.log,
})
: null;
let pageErrors = [];
let editor = null;
let result = { failures: [], performed: 0, skipped: 0 };
const screenshotPath = name =>
reporter.getScreenshotPath(`${test.name}-${name}`);
try {
editor = await launchEditor({
binaryPath,
projectPath,
debuggingPort: DEBUGGING_PORT,
log: reporter.log,
});
const page = editor.page;
pageErrors = watchPageErrors(page);
// The helpers are installed in the already loaded page: the window of the
// editor is opened by the app itself, not by the test.
const helpers = test.helpers || (test.helper ? [test.helper] : []);
await page.evaluate(`(${installPageHelpers.toString()})()`);
for (const helper of helpers) {
if (helper.installPageHelpers)
await page.evaluate(`(${helper.installPageHelpers.toString()})()`);
}
result = await test.run({
page,
pageErrors,
reporter,
version,
projectPath,
options,
screenshot: async name => {
await page.screenshot({ path: screenshotPath(name) });
},
// The same runners as the Storybook tests, already bound to this page.
runSteps: ({ helper, steps }) =>
runSteps({ page, pageErrors, helper, steps, reporter }),
runMonkey: ({ helper, seed, steps, actionNames }) =>
runMonkey({
page,
pageErrors,
helper,
seed: seed || 1,
steps: steps || options.editorMonkeySteps,
actionNames,
reporter,
verbose: options.verbose,
}),
});
} catch (error) {
reporter.log(` ❌ ${error.message || String(error)}`);
result.failures = [...result.failures, error.message || String(error)];
if (editor) {
try {
await editor.page.screenshot({ path: screenshotPath('failure') });
} catch (screenshotError) {
// Ignore: the window may already be gone.
}
}
} finally {
if (editor) {
await wait(500);
await editor.stop();
await wait(1000);
}
}
if (pageErrors.length && !result.failures.length) {
result.failures.push(`the editor threw: ${pageErrors[0].split('\n')[0]}`);
reporter.log(` 💥 the editor threw: ${pageErrors[0].split('\n')[0]}`);
}
reporter.addResult({ name: test.name, ...result });
}
};
module.exports = { runEditorSuite };
+27
View File
@@ -0,0 +1,27 @@
// @ts-check
/**
* Manipulations that make sense in any editor. An editor specific helper adds
* its own to these (see `helpers/SpriteEditor.js`).
*/
const { scrollList, wait } = require('./PageDriver');
const genericActions = {
scrollList: {
describe: args => `scroll the list by ${args.delta}px`,
pick: (state, random) => ({
delta: (random() < 0.5 ? -1 : 1) * (300 + Math.floor(random() * 900)),
}),
run: async (page, args) => {
await scrollList(page, args.delta);
await wait(400);
},
},
};
const genericMonkeyWeights = {
scrollList: 2,
};
module.exports = { genericActions, genericMonkeyWeights };
+29
View File
@@ -0,0 +1,29 @@
// @ts-check
/**
* Problems that these tests do find, but that are not what they are here to
* guard: they are reported (loudly, in the log and in the summary) without
* failing the run, so that a branch is not blocked by something it did not
* introduce.
*
* Remove an entry as soon as it is fixed: the run then fails again if it comes
* back.
*/
const knownIssues = [
{
name: 'react-dnd loses a drag source',
matches: /Expected sourceIds to be registered/,
explanation:
'A drag can throw `Invariant Violation: Expected sourceIds to be ' +
'registered` from react-dnd, which takes the editor down. It happens ' +
'after tens of manipulations, entirely inside react-dnd (the drag ' +
'source of the pressed element is not registered anymore when the drag ' +
'begins), and it also happens with the code that was there before these ' +
'tests - so it is not a regression of the editor itself.',
},
];
const findKnownIssue = message =>
knownIssues.find(issue => issue.matches.test(message)) || null;
module.exports = { knownIssues, findKnownIssue };
+157
View File
@@ -0,0 +1,157 @@
// @ts-check
/**
* Drives a page from the test: the generic helpers installed in it, plus what
* needs real input events (drag and drop, keyboard).
*/
const wait = durationInMs =>
new Promise(resolve => setTimeout(resolve, durationInMs));
const click = (page, target) =>
page.evaluate(theTarget => window.gdVisualTests.click(theTarget), target);
const exists = (page, target) =>
page.evaluate(theTarget => window.gdVisualTests.exists(theTarget), target);
const setInputValue = (page, target, value) =>
page.evaluate(
(theTarget, theValue) =>
window.gdVisualTests.setInputValue(theTarget, theValue),
target,
value
);
const typeInInput = (page, target, value) =>
page.evaluate(
(theTarget, theValue) =>
window.gdVisualTests.typeInInput(theTarget, theValue),
target,
value
);
const rectOf = (page, target, shouldScroll) =>
page.evaluate(
(theTarget, scroll) => window.gdVisualTests.rectOf(theTarget, scroll),
target,
shouldScroll
);
const openContextMenu = (page, target) =>
page.evaluate(
theTarget => window.gdVisualTests.openContextMenu(theTarget),
target
);
const scrollList = (page, delta, target) =>
page.evaluate(
(theDelta, theTarget) =>
window.gdVisualTests.scrollList(theDelta, theTarget),
delta,
target
);
const describeOverlays = page =>
page.evaluate(() => window.gdVisualTests.describeOverlays());
/** Drag and drop with real mouse events (the drag backend handles them). */
const dragFromTo = async (page, fromTarget, toTarget, dropAfter) => {
const from = await rectOf(page, fromTarget, true);
if (!from) return false;
const to = await rectOf(page, toTarget, false);
if (!to) return false;
await page.mouse.move(from.x, from.y);
await page.mouse.down();
// More than `touchSlop` (10px) is needed for the drag to start.
await page.mouse.move(from.x + 16, from.y + 4, { steps: 4 });
const targetX = dropAfter ? to.x + to.width / 3 : to.x;
await page.mouse.move(targetX, to.y, { steps: 10 });
await page.mouse.move(targetX, to.y + 2, { steps: 2 });
await wait(150);
await page.mouse.up();
await wait(400);
return true;
};
/** Collect the uncaught errors of a page (and the errors React reports). */
const watchPageErrors = page => {
const pageErrors = [];
page.on('pageerror', error => {
const message = error.message || String(error);
pageErrors.push(
message +
(error.stack
? '\n' +
error.stack
.split('\n')
.slice(0, 8)
.join('\n')
: '')
);
});
page.on('console', message => {
const text = message.text();
if (
message.type() === 'error' &&
text.includes('The above error occurred in the')
)
pageErrors.push('React reports: ' + text.split('\n')[1]);
});
return pageErrors;
};
/** Wait for something to be there, without failing the test. */
const waitFor = async (page, target, timeoutInMs = 10000) => {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutInMs) {
if (await exists(page, target)) return true;
await wait(300);
}
return false;
};
const closeAnyMenu = async page => {
const { openMenuItems } = await describeOverlays(page);
if (!openMenuItems.length) return;
await page.keyboard.press('Escape');
await wait(300);
};
/** Close whatever is opened over the editor, so that it is usable again. */
const closeAnyOverlay = async page => {
const { openMenuItems, openDialogTitles } = await describeOverlays(page);
if (openMenuItems.length) {
await page.keyboard.press('Escape');
await wait(300);
return true;
}
if (openDialogTitles.length) {
const closed =
(await click(page, { dialogButton: 'Apply' })) ||
(await click(page, { dialogButton: 'Ok' })) ||
(await click(page, { dialogButton: 'Close' })) ||
(await click(page, { dialogButton: 'Cancel' }));
if (!closed) await page.keyboard.press('Escape');
await wait(500);
return true;
}
return false;
};
module.exports = {
wait,
click,
exists,
setInputValue,
typeInInput,
rectOf,
openContextMenu,
scrollList,
describeOverlays,
dragFromTo,
watchPageErrors,
waitFor,
closeAnyMenu,
closeAnyOverlay,
};
+232
View File
@@ -0,0 +1,232 @@
// @ts-check
/**
* Generic helpers installed in the page under `window.gdVisualTests`, to find
* and use the controls of any editor.
*
* Elements are designated by a "target", one of:
* - `{ selector: '#apply-button' }`
* - `{ button: 'Add a' }` (a button containing this text)
* - `{ exactButton: 'Apply' }`
* - `{ dialogButton: 'Apply' }` (in the dialog on top, as several dialogs can
* be opened one over the other and have a button with the same label)
* - `{ menuItem: 'Move to top' }`
* - `{ tab: 'Behaviors' }`
* - `{ text: 'Add your first animation' }`
*
* An editor with its own way of designating things (a row of a list, a frame...)
* registers a resolver with `addTargetResolver`, and its targets are then
* `{ <resolverName>: { ...whatever it needs } }`.
*/
const installPageHelpers = function() {
const isLeaf = element => element.children.length === 0;
const textOf = element => (element.textContent || '').trim();
const findButton = (text, exact, inTopMostDialog) => {
let root = document;
if (inTopMostDialog) {
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]'));
root = dialogs[dialogs.length - 1];
if (!root) return null;
}
return (
Array.from(root.querySelectorAll('button')).find(button =>
exact ? textOf(button) === text : textOf(button).includes(text)
) || null
);
};
const findMenuItem = text => {
const items = Array.from(
document.querySelectorAll('[role="menuitem"], li')
);
const exactItem = items.find(item => textOf(item) === text);
if (exactItem) return exactItem;
// A built Storybook does not interpolate the translated labels, so they all
// show the same placeholder ("Position {index}"): the one to click is then
// found by its position.
const match = text.match(/^(.*?)(\d+)$/);
if (!match) return null;
const itemsWithAPlaceholder = items.filter(item => {
const itemText = textOf(item);
return itemText.startsWith(match[1]) && /\{[a-zA-Z]+\}$/.test(itemText);
});
return itemsWithAPlaceholder[Number(match[2]) - 1] || null;
};
const findByText = text =>
Array.from(document.querySelectorAll('*')).find(
element => isLeaf(element) && textOf(element) === text
) || null;
const findTab = text =>
Array.from(document.querySelectorAll('[role="tab"], .MuiTab-root')).find(
tab => textOf(tab).includes(text)
) || null;
/** The outermost scrollable list of the page, or the one containing an element. */
const getScrollableList = elementInside => {
const candidates = Array.from(document.querySelectorAll('div')).filter(
element => {
const { overflowY } = window.getComputedStyle(element);
return (
(overflowY === 'auto' || overflowY === 'scroll') &&
// Storybook and the editor have hidden scrollable wrappers.
element.scrollHeight > 0
);
}
);
if (elementInside) {
const containing = candidates.filter(element =>
element.contains(elementInside)
);
if (containing.length) return containing[0];
}
return candidates.length ? candidates[0] : document.body;
};
const targetResolvers = {};
const locate = target => {
if (!target) return null;
if (target.selector) return document.querySelector(target.selector);
if (target.button) return findButton(target.button, false, false);
if (target.exactButton) return findButton(target.exactButton, true, false);
if (target.dialogButton) return findButton(target.dialogButton, true, true);
if (target.menuItem) return findMenuItem(target.menuItem);
if (target.tab) return findTab(target.tab);
if (target.text) return findByText(target.text);
for (const name in targetResolvers) {
if (target[name]) return targetResolvers[name](target[name]);
}
return null;
};
const click = target => {
const element = locate(target);
if (!element) return false;
element.scrollIntoView({ block: 'center' });
element.click();
return true;
};
const setInputValue = (target, value) => {
const element = locate(target);
if (!element) return false;
const setValue = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set;
element.scrollIntoView({ block: 'center' });
element.focus();
setValue.call(element, value);
element.dispatchEvent(new Event('input', { bubbles: true }));
element.blur();
return true;
};
/** Like a user typing, for the fields reacting to every key stroke. */
const typeInInput = (target, value) => {
const element = locate(target);
if (!element) return false;
const setValue = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set;
element.scrollIntoView({ block: 'center' });
element.focus();
setValue.call(element, value);
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
return true;
};
/** The position of an element, to press it or drop something on it. */
const rectOf = (target, shouldScroll) => {
const element = locate(target);
if (!element) return null;
if (shouldScroll !== false) element.scrollIntoView({ block: 'center' });
const rect = element.getBoundingClientRect();
if (
rect.width === 0 ||
rect.height === 0 ||
rect.top < 0 ||
rect.bottom > window.innerHeight ||
rect.left < 0 ||
rect.right > window.innerWidth
)
return null;
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
// The element must really be there: not scrolled out of its list or
// behind something else (its rect would still be within the window).
const elementAtPoint = document.elementFromPoint(x, y);
if (
!elementAtPoint ||
!(element.contains(elementAtPoint) || elementAtPoint.contains(element))
)
return null;
return { x, y, width: rect.width, height: rect.height };
};
const openContextMenu = target => {
const element = locate(target);
if (!element) return false;
element.scrollIntoView({ block: 'center' });
const rect = element.getBoundingClientRect();
element.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
clientX: rect.left + 10,
clientY: rect.top + 10,
})
);
return true;
};
const scrollList = (delta, target) => {
const list = getScrollableList(target ? locate(target) : null);
const from = list.scrollTop;
list.scrollTop = from + delta;
return {
from,
to: list.scrollTop,
max: list.scrollHeight - list.clientHeight,
};
};
/** What is currently opened over the editor, and its texts. */
const describeOverlays = () => ({
openDialogTitles: Array.from(
document.querySelectorAll('[role="dialog"] h1, [role="dialog"] h2')
).map(title => textOf(title)),
openMenuItems: Array.from(
document.querySelectorAll('[role="menuitem"]')
).map(item => textOf(item)),
});
window.gdVisualTests = {
// Used by the editor specific helpers.
isLeaf,
textOf,
getScrollableList,
addTargetResolver: (name, resolver) => {
targetResolvers[name] = resolver;
},
// Used by the tests.
locate,
click,
setInputValue,
typeInInput,
rectOf,
openContextMenu,
scrollList,
describeOverlays,
exists: target => !!locate(target),
textsOf: selector =>
Array.from(document.querySelectorAll(selector)).map(textOf),
};
};
module.exports = { installPageHelpers };
+246
View File
@@ -0,0 +1,246 @@
// @ts-check
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const AdmZip = require('adm-zip');
const puppeteer = require('puppeteer');
const { downloadFile, formatBytes } = require('./Download');
const electronAppPackageJson = require('../../electron-app/app/package.json');
const EXAMPLES_REPOSITORY =
'https://github.com/GDevelopApp/GDevelop-examples.git';
const EXAMPLES_BRANCH = 'main';
const wait = durationInMs =>
new Promise(resolve => setTimeout(resolve, durationInMs));
/**
* Find the Electron executable of an extracted portable build (named after the
* `name` field of the package.json by electron-builder).
*/
const findExecutable = root => {
const candidates = ['gdevelop', 'GDevelop'];
const stack = [root];
while (stack.length) {
const directory = stack.pop();
if (!directory) continue;
let entries;
try {
entries = fs.readdirSync(directory, { withFileTypes: true });
} catch (error) {
continue;
}
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) stack.push(fullPath);
else if (entry.isFile() && candidates.includes(entry.name))
return fullPath;
}
}
return null;
};
/**
* The version built by a branch: the one of its electron-app package.json,
* when the branch is known to git - the version of this checkout otherwise.
* Downloading the build of another branch (the tests of a branch run against
* the latest build of `master`) must use the version of that branch: this
* checkout may have bumped it, to a version not uploaded anywhere yet.
*/
const getVersionOfBranch = branch => {
const result = spawnSync(
'git',
['show', `origin/${branch}:newIDE/electron-app/app/package.json`],
{ cwd: path.resolve(__dirname, '..', '..', '..'), encoding: 'utf8' }
);
try {
return JSON.parse(result.stdout).version;
} catch (error) {
return electronAppPackageJson.version;
}
};
/**
* Get the portable Linux build to test: either a local zip (the one a
* build-linux job just produced) or the latest one uploaded to S3.
*/
const getPortableBuild = async ({ zipPath, branch, workDirectory, log }) => {
const version = zipPath
? electronAppPackageJson.version
: getVersionOfBranch(branch);
fs.mkdirSync(workDirectory, { recursive: true });
let portableZipPath = zipPath;
if (portableZipPath) {
if (!fs.existsSync(portableZipPath))
throw new Error(`The given zip does not exist: ${portableZipPath}`);
log(`Using the local portable build ${portableZipPath}.`);
} else {
const zipName = `gdevelop-${version}.zip`;
const url = `https://gdevelop-releases.s3.amazonaws.com/${branch}/latest/${zipName}`;
portableZipPath = path.join(workDirectory, zipName);
if (fs.existsSync(portableZipPath)) {
log(`Using the already downloaded ${portableZipPath}.`);
} else {
log(`Downloading ${url}...`);
await downloadFile(url, portableZipPath);
log(
`Downloaded ${zipName} (${formatBytes(
fs.statSync(portableZipPath).size
)}).`
);
}
}
const extractedDirectory = path.join(workDirectory, `gdevelop-${version}`);
if (!findExecutable(extractedDirectory)) {
log(`Extracting the portable build to ${extractedDirectory}...`);
fs.mkdirSync(extractedDirectory, { recursive: true });
new AdmZip(portableZipPath).extractAllTo(extractedDirectory, true);
}
const binaryPath = findExecutable(extractedDirectory);
if (!binaryPath)
throw new Error(
`No GDevelop executable found in ${extractedDirectory}. Contents: ` +
fs.readdirSync(extractedDirectory).join(', ')
);
fs.chmodSync(binaryPath, 0o755);
log(`GDevelop ${version}: ${binaryPath}`);
return { binaryPath, version };
};
/**
* Sparse-clone one example of the GDevelop-examples repository (the whole
* repository is far too big) and return the path of its project file.
*/
const getExampleProject = ({ slug, workDirectory, log }) => {
const exampleParentDirectory = path.join(workDirectory, 'examples', slug);
const projectPath = path.join(
exampleParentDirectory,
'examples',
slug,
`${slug}.json`
);
if (fs.existsSync(projectPath)) {
log(`Using the already cloned example ${slug}.`);
return projectPath;
}
log(`Cloning the example "${slug}" from GDevelop-examples...`);
fs.mkdirSync(exampleParentDirectory, { recursive: true });
const run = command => {
const result = spawnSync(command[0], command.slice(1), {
cwd: exampleParentDirectory,
stdio: 'inherit',
});
if (result.status !== 0)
throw new Error(`Command failed: ${command.join(' ')}`);
};
run(['git', 'init', '-q']);
run(['git', 'remote', 'add', 'origin', EXAMPLES_REPOSITORY]);
run(['git', 'sparse-checkout', 'init', '--cone']);
run(['git', 'sparse-checkout', 'set', `examples/${slug}`]);
run(['git', 'fetch', '--depth=1', 'origin', EXAMPLES_BRANCH]);
run(['git', 'checkout', '-q', 'FETCH_HEAD']);
if (!fs.existsSync(projectPath))
throw new Error(`No project file at ${projectPath} after the clone.`);
return projectPath;
};
/**
* Start the real editor on a project and connect to its window, so that it can
* be driven exactly like a page of the Storybook.
*/
const launchEditor = async ({
binaryPath,
projectPath,
debuggingPort,
log,
}) => {
const userDataDirectory = fs.mkdtempSync(
path.join(os.tmpdir(), 'gdevelop-visual-tests-')
);
const args = [
'--no-sandbox',
'--disable-update-check',
'--disable-gpu',
`--remote-debugging-port=${debuggingPort}`,
`--user-data-dir=${userDataDirectory}`,
];
if (projectPath) args.push(projectPath);
log(`Starting the editor: ${path.basename(binaryPath)} ${args.join(' ')}`);
const child = spawn(binaryPath, args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env },
});
const output = [];
const collect = data => {
output.push(data.toString());
if (output.length > 200) output.shift();
};
if (child.stdout) child.stdout.on('data', collect);
if (child.stderr) child.stderr.on('data', collect);
let browser = null;
const startedAt = Date.now();
while (Date.now() - startedAt < 120000) {
if (child.exitCode !== null)
throw new Error(
`The editor exited with ${child.exitCode}:\n${output.join('')}`
);
try {
browser = await puppeteer.connect({
browserURL: `http://127.0.0.1:${debuggingPort}`,
defaultViewport: null,
});
break;
} catch (error) {
await wait(1000);
}
}
if (!browser)
throw new Error(
`Could not connect to the editor on port ${debuggingPort}:\n` +
output.join('')
);
// Find the window of the editor itself (there can be other targets).
let page = null;
const foundAt = Date.now();
while (Date.now() - foundAt < 60000) {
const pages = await browser.pages();
page = pages.find(onePage => !onePage.url().startsWith('devtools://'));
if (page) break;
await wait(500);
}
if (!page) throw new Error('No page found in the editor.');
log(`Connected to the editor window (${page.url()}).`);
// Without a window manager (like on the CI), the window stays at its small
// default size: give the editor the same room as the Storybook tests.
await page.setViewport({ width: 1500, height: 1000 });
const stop = async () => {
try {
await browser.disconnect();
} catch (error) {
// Ignore: the editor may already be gone.
}
child.kill('SIGKILL');
};
return { browser, page, stop, getOutput: () => output.join('') };
};
module.exports = {
getPortableBuild,
getExampleProject,
launchEditor,
findExecutable,
};
+168
View File
@@ -0,0 +1,168 @@
// @ts-check
const fs = require('fs');
const path = require('path');
const { knownIssues } = require('./KnownIssues');
const escapeXml = text =>
String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
/**
* Collects everything the run says: on the console, in a log file stored with
* the screenshots, and optionally as a JUnit file (which the CI uses to show
* the results and to balance the tests across the parallel containers).
*/
const makeReporter = ({
artifactsDirectory,
logFileName,
junitPath,
suiteName,
}) => {
const lines = [];
const results = [];
let lastResultAt = Date.now();
fs.mkdirSync(artifactsDirectory, { recursive: true });
const log = message => {
const line = `${new Date().toISOString().slice(11, 19)} ${message}`;
console.log(line);
lines.push(line);
};
const addResult = ({ name, failures, performed, skipped, knownIssues }) => {
results.push({
name,
// The name of the test, without the seed of the random session: this is
// what the CI splits the tests by, and what it records durations for.
testName: name.replace(/ \(seed \d+\)$/, ''),
failures,
performed,
skipped,
knownIssues: knownIssues || [],
durationInSeconds: (Date.now() - lastResultAt) / 1000,
});
lastResultAt = Date.now();
log(
` ${performed} manipulations performed, ${skipped} skipped, ` +
(failures.length
? `❌ ${failures.length} problem(s)`
: (knownIssues || []).length
? '⚠️ stopped by a known issue'
: '✅ no crash, and the editor stayed consistent')
);
};
const writeJunit = () => {
if (!junitPath) return;
const testCases = results
.map(result => {
const attributes =
`classname="${escapeXml(suiteName)}" ` +
`name="${escapeXml(result.name)}" ` +
`file="${escapeXml(result.testName)}" ` +
`time="${result.durationInSeconds.toFixed(1)}"`;
if (result.failures.length)
return (
` <testcase ${attributes}>\n` +
` <failure message="${escapeXml(result.failures[0])}">` +
`${escapeXml(result.failures.join('\n'))}</failure>\n` +
` </testcase>`
);
if (result.knownIssues.length)
return (
` <testcase ${attributes}>\n` +
` <skipped message="${escapeXml(result.knownIssues[0])}"/>\n` +
` </testcase>`
);
return ` <testcase ${attributes}/>`;
})
.join('\n');
const failuresCount = results.filter(result => result.failures.length)
.length;
fs.mkdirSync(path.dirname(junitPath), { recursive: true });
fs.writeFileSync(
junitPath,
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuites>\n` +
` <testsuite name="${escapeXml(suiteName)}" tests="${
results.length
}" failures="${failuresCount}">\n${testCases}\n </testsuite>\n` +
`</testsuites>\n`
);
};
const writeSummary = () => {
const failed = results.filter(result => result.failures.length);
const withKnownIssues = results.filter(
result => !result.failures.length && result.knownIssues.length
);
const performed = results.reduce(
(total, result) => total + result.performed,
0
);
log('');
log(
'================================ SUMMARY ================================'
);
results.forEach(result =>
log(
`${
result.failures.length
? '❌'
: result.knownIssues.length
? '⚠️'
: '✅'
} ${result.name}: ${result.performed} manipulations` +
(result.failures.length ? ` - ${result.failures[0]}` : '') +
(!result.failures.length && result.knownIssues.length
? ` - ${result.knownIssues[0]}`
: '')
)
);
log(
`${results.length - failed.length - withKnownIssues.length}/${
results.length
} passed, ${performed} manipulations performed in total.`
);
if (withKnownIssues.length) {
log('');
log(
`${
withKnownIssues.length
} test(s) were stopped by a known issue (see ` +
'lib/KnownIssues.js), which does not fail the run:'
);
knownIssues.forEach(issue =>
log(`- ${issue.name}: ${issue.explanation}`)
);
}
fs.writeFileSync(
path.join(artifactsDirectory, logFileName),
lines.join('\n') + '\n'
);
fs.writeFileSync(
path.join(artifactsDirectory, logFileName.replace(/\.log$/, '.json')),
JSON.stringify({ results }, null, 2)
);
writeJunit();
return failed.length === 0;
};
const getScreenshotPath = name =>
path.join(artifactsDirectory, `${name.replace(/[^a-z0-9]+/gi, '-')}.png`);
return {
log,
addResult,
writeSummary,
getScreenshotPath,
};
};
module.exports = { makeReporter };
+260
View File
@@ -0,0 +1,260 @@
// @ts-check
/**
* Runs the manipulations of a test - scripted, or a random sequence of them
* (the "monkey") - and checks after every single one that:
* - the page did not throw (an uncaught error takes the whole editor down),
* - the editor still shows what it is editing (`helper.check`),
* - the manipulation did what it was supposed to do.
*
* Everything specific to an editor comes from its helper (see the table in
* README.md, and `helpers/SpriteEditor.js` for a complete example): the
* manipulations, how to read what is displayed, and how to check it.
*/
const { closeAnyOverlay } = require('./PageDriver');
const { findKnownIssue } = require('./KnownIssues');
/** A reproducible pseudo random number generator. */
const makeRandom = seed => {
let state = seed * 7919 + 13;
return () => {
state = (state * 1103515245 + 12345) & 0x7fffffff;
return state / 0x7fffffff;
};
};
/**
* What the manipulations may change, as the helper reads it - so that a
* manipulation that did nothing can be told apart from one that worked.
*/
const takeSnapshot = async (page, helper) =>
helper.snapshot ? await helper.snapshot(page) : await helper.describe(page);
/** Run one manipulation and check everything afterwards. */
const runStep = async ({
page,
pageErrors,
helper,
actionName,
args,
random,
}) => {
const action = helper.actions[actionName];
if (!action)
throw new Error(
`Unknown manipulation "${actionName}" for the ${helper.name} helper.`
);
const stateBefore = await helper.describe(page);
const stepArgs =
args || (action.pick ? action.pick(stateBefore, random) : {});
if (!stepArgs) return { skipped: 'not applicable' };
const description = action.describe(stepArgs);
const snapshotBefore = await takeSnapshot(page, helper);
const expectation = action.expect
? action.expect(stateBefore, stepArgs)
: null;
let problem = null;
try {
problem = await action.run(page, stepArgs);
} catch (error) {
problem = `the manipulation threw: ${(error.message || String(error)).slice(
0,
200
)}`;
}
if (pageErrors.length) return { description, crashed: true };
if (problem) return { description, skipped: problem };
const { problems } = await helper.check(page);
const snapshotAfter = await takeSnapshot(page, helper);
const hadNoEffect =
JSON.stringify(snapshotBefore) === JSON.stringify(snapshotAfter);
const effect = hadNoEffect
? 'nothing changed'
: helper.describeEffect
? helper.describeEffect(snapshotBefore, snapshotAfter)
: 'something changed';
if (action.mustChangeTheObject && hadNoEffect)
problems.push('the object was not changed');
if (expectation && helper.checkExpectation)
problems.push(...helper.checkExpectation(expectation, snapshotAfter));
// The invariants the helper declares (see `helper.stepChecks` in README.md):
// each one is checked after every manipulation flagged with its name.
const stepChecks = helper.stepChecks || {};
for (const checkName of Object.keys(stepChecks)) {
if (!action[checkName]) continue;
problems.push(
...(await stepChecks[checkName]({
page,
snapshotBefore,
snapshotAfter,
hadNoEffect,
}))
);
}
return {
description,
problems,
effect,
hadNoEffect,
isDrop: !!action.mayHaveNoEffect,
};
};
/** Report a step that went wrong, as a failure or as a known issue. */
const reportStepResult = ({ reporter, result, prefix, pageErrors }) => {
if (result.crashed) {
const message = pageErrors[0] || '';
const knownIssue = findKnownIssue(message);
if (knownIssue) {
reporter.log(
` ⚠️ ${prefix}${
result.description
}: the editor was taken down by a ` + `known issue (${knownIssue.name})`
);
reporter.log(` ${message.split('\n')[0]}`);
return { failures: [], knownIssues: [`${knownIssue.name}`] };
}
reporter.log(` 💥 ${prefix}${result.description}: THE EDITOR CRASHED`);
message
.split('\n')
.slice(0, 6)
.forEach(line => reporter.log(` ${line.trim()}`));
return {
failures: [`crash after: ${result.description}`],
knownIssues: [],
};
}
if (result.problems.length) {
reporter.log(` ❌ ${prefix}${result.description}`);
result.problems.forEach(problem => reporter.log(` ${problem}`));
return {
failures: result.problems.map(
problem => `${result.description}: ${problem}`
),
knownIssues: [],
};
}
return { failures: [], knownIssues: [] };
};
/** Run the scripted manipulations of a test. */
const runSteps = async ({ page, pageErrors, helper, steps, reporter }) => {
const random = makeRandom(1);
const failures = [];
const knownIssues = [];
let performed = 0;
let skipped = 0;
let drops = 0;
let effectiveDrops = 0;
for (const [actionName, args] of steps) {
const result = await runStep({
page,
pageErrors,
helper,
actionName,
args,
random,
});
if (result.skipped) {
skipped++;
reporter.log(
` • ${result.description || actionName}: skipped (${result.skipped})`
);
continue;
}
performed++;
if (result.isDrop) {
drops++;
if (!result.hadNoEffect) effectiveDrops++;
}
const stepResult = reportStepResult({
reporter,
result,
prefix: '',
pageErrors,
});
failures.push(...stepResult.failures);
knownIssues.push(...stepResult.knownIssues);
if (stepResult.failures.length || stepResult.knownIssues.length) break;
reporter.log(` ✓ ${result.description} → ${result.effect}`);
}
if (drops > 0 && effectiveDrops === 0 && !knownIssues.length) {
failures.push('none of the drag and drops moved anything');
reporter.log(' ❌ none of the drag and drops moved anything');
}
return { failures, knownIssues, performed, skipped };
};
/** Run a random sequence of manipulations. */
const runMonkey = async ({
page,
pageErrors,
helper,
seed,
steps,
actionNames,
reporter,
verbose,
}) => {
const random = makeRandom(seed);
const weights = helper.monkeyWeights;
const weighted = [];
(actionNames || Object.keys(weights)).forEach(name => {
for (let index = 0; index < (weights[name] || 1); index++)
weighted.push(name);
});
const failures = [];
const knownIssues = [];
let performed = 0;
let skipped = 0;
for (
let step = 0;
step < steps && !failures.length && !knownIssues.length;
step++
) {
if (await closeAnyOverlay(page)) continue;
const actionName = weighted[Math.floor(random() * weighted.length)];
const result = await runStep({
page,
pageErrors,
helper,
actionName,
args: null,
random,
});
if (result.skipped) {
skipped++;
continue;
}
performed++;
const stepResult = reportStepResult({
reporter,
result,
prefix: `step ${step + 1}, `,
pageErrors,
});
failures.push(...stepResult.failures);
knownIssues.push(...stepResult.knownIssues);
if (stepResult.failures.length || stepResult.knownIssues.length) break;
if (verbose || step % 10 === 0)
reporter.log(
` ✓ step ${step + 1}: ${result.description} → ${result.effect}`
);
}
return { failures, knownIssues, performed, skipped };
};
module.exports = { makeRandom, runStep, runSteps, runMonkey };
@@ -0,0 +1,60 @@
// @ts-check
const fs = require('fs');
const http = require('http');
const path = require('path');
const { spawn } = require('child_process');
const serveHandler = require('serve-handler');
const newIdeAppDirectory = path.resolve(__dirname, '..', '..', 'app');
const defaultBuildDirectory = path.join(newIdeAppDirectory, 'build-storybook');
const runCommand = (command, args, cwd) =>
new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd, stdio: 'inherit', shell: false });
child.on('error', reject);
child.on('exit', code =>
code === 0
? resolve()
: reject(new Error(`${command} ${args.join(' ')} exited with ${code}`))
);
});
/**
* Serve a Storybook to run the tests against: either an already running one
* (`--storybook-url`), or the static build - built if it is not there yet.
*/
const startStorybook = async ({ storybookUrl, port, rebuild, log }) => {
if (storybookUrl) {
log(`Using the Storybook already running on ${storybookUrl}.`);
return { url: storybookUrl, stop: async () => {} };
}
const buildDirectory = defaultBuildDirectory;
if (rebuild || !fs.existsSync(path.join(buildDirectory, 'iframe.html'))) {
log('Building Storybook (this takes a few minutes)...');
await runCommand('npm', ['run', 'build-storybook'], newIdeAppDirectory);
} else {
log(`Using the Storybook already built in ${buildDirectory}.`);
}
const server = http.createServer((request, response) =>
serveHandler(request, response, {
public: buildDirectory,
etag: true,
// Without this, `/iframe.html?id=...` is redirected to `/iframe` and the
// story to show is lost on the way.
cleanUrls: false,
})
);
await new Promise(resolve => server.listen(port, resolve));
const url = `http://localhost:${port}`;
log(`Storybook served on ${url}.`);
return {
url,
stop: () => new Promise(resolve => server.close(() => resolve())),
};
};
module.exports = { startStorybook, defaultBuildDirectory };
+156
View File
@@ -0,0 +1,156 @@
// @ts-check
const puppeteer = require('puppeteer');
const { installPageHelpers } = require('./PageHelpers');
const { startStorybook } = require('./StorybookServer');
const { runSteps, runMonkey } = require('./Runner');
const { wait, watchPageErrors } = require('./PageDriver');
const { findChrome } = require('./Chrome');
/** Install the generic page helpers, then the ones of the editor being tested. */
const getPageHelpersScript = helper =>
`(${installPageHelpers.toString()})();` +
(helper && helper.installPageHelpers
? `(${helper.installPageHelpers.toString()})();`
: '');
/**
* Wait for the story to be rendered and consistent: the editor takes a while
* to display everything (GDevelop.js must load first), and `helper.check`
* reports problems until it does. Returns the problems left on timeout.
*/
const waitUntilConsistent = async (page, helper, timeoutInMs) => {
const startedAt = Date.now();
let problems = [];
while (Date.now() - startedAt < timeoutInMs) {
try {
problems = (await helper.check(page)).problems;
} catch (error) {
problems = [error.message || String(error)];
}
if (!problems.length) return [];
await wait(500);
}
return problems;
};
const openStory = async ({ browser, storybookUrl, test, headful }) => {
const page = await browser.newPage();
if (!headful) await page.setViewport({ width: 1500, height: 1000 });
const pageErrors = watchPageErrors(page);
try {
await page.evaluateOnNewDocument(getPageHelpersScript(test.helper));
await page.goto(
`${storybookUrl}/iframe.html?id=${test.story}&viewMode=story`,
{ waitUntil: 'domcontentloaded', timeout: 120000 }
);
const problems = await waitUntilConsistent(page, test.helper, 180000);
if (problems.length)
throw new Error(`The story was not displayed: ${problems.join('; ')}`);
} catch (error) {
await page.close();
throw error;
}
await wait(1500);
return { page, pageErrors };
};
/** Run the tests written against the Storybook stories. */
const runStorybookSuite = async ({ tests, options, reporter }) => {
const storybook = await startStorybook({
storybookUrl: options.storybookUrl,
port: options.storybookPort,
rebuild: options.rebuildStorybook,
log: reporter.log,
});
const browser = await puppeteer.launch({
executablePath: findChrome(options.chromePath),
headless: options.headful ? false : 'new',
slowMo: options.headful ? 20 : 0,
defaultViewport: options.headful ? null : { width: 1500, height: 1000 },
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
options.headful ? '--window-size=1900,1140' : '--window-size=1500,1000',
'--window-position=0,0',
],
});
try {
for (const test of tests) {
const runs = test.monkey
? test.monkey.seeds.map(seed => ({ seed }))
: [{ seed: null }];
for (const { seed } of runs) {
const name = seed === null ? test.name : `${test.name} (seed ${seed})`;
reporter.log('');
reporter.log(`TEST ${name}`);
if (test.description) reporter.log(` ${test.description}`);
let page = null;
try {
const opened = await openStory({
browser,
storybookUrl: storybook.url,
test,
headful: options.headful,
});
page = opened.page;
const pageErrors = opened.pageErrors;
if (test.helper.summarize)
reporter.log(` ${await test.helper.summarize(page)}`);
const result = test.monkey
? await runMonkey({
page,
pageErrors,
helper: test.helper,
seed,
steps: test.monkey.steps,
reporter,
verbose: options.verbose,
})
: await runSteps({
page,
pageErrors,
helper: test.helper,
steps: test.steps,
reporter,
});
const final = await test.helper.check(page);
if (
!result.failures.length &&
!result.knownIssues.length &&
final.problems.length
) {
result.failures.push(...final.problems);
reporter.log(` ❌ final check: ${final.problems.join('; ')}`);
}
reporter.addResult({ name, ...result });
} catch (error) {
const message = error.message || String(error);
reporter.log(` ❌ ${message}`);
reporter.addResult({
name,
failures: [message],
performed: 0,
skipped: 0,
});
}
if (page) {
await page.screenshot({ path: reporter.getScreenshotPath(name) });
await page.close();
}
}
}
} finally {
await browser.close();
await storybook.stop();
}
};
module.exports = { runStorybookSuite };
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "gdevelop-visual-tests",
"version": "1.0.0",
"private": true,
"license": "MIT",
"description": "Visual monkey tests: they manipulate the GDevelop editor in a real browser (Storybook) and in the real packaged app, and check that nothing is broken.",
"main": "run.js",
"scripts": {
"list": "node run.js --list",
"test": "node run.js --suite=all",
"test-storybook": "node run.js --suite=storybook",
"test-editor": "node run.js --suite=editor"
},
"dependencies": {
"adm-zip": "^0.5.10",
"minimist": "1.2.5",
"puppeteer": "^24.15.0",
"serve-handler": "6.1.6"
}
}
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env node
// @ts-check
/**
* Runs the visual monkey tests: they manipulate the editor in a real browser
* and check that nothing is broken. See README.md.
*
* Examples:
* node run.js --list
* node run.js --suite=storybook
* node run.js --suite=storybook --test=monkey --headful
* node run.js --suite=storybook --only-changed --base-ref=origin/master
* node run.js --suite=editor --gdevelop-zip=../electron-app/dist/gdevelop.zip
*/
const fs = require('fs');
const path = require('path');
const minimist = require('minimist');
const { makeReporter } = require('./lib/Reporter');
const { runStorybookSuite } = require('./lib/StorybookSuite');
const { runEditorSuite } = require('./lib/EditorSuite');
const {
getChangedFiles,
filterTestsForChangedFiles,
} = require('./lib/ChangedFiles');
const SUITES = {
storybook: {
directory: 'storybook-tests',
run: runStorybookSuite,
description: 'Storybook stories of a single editor, checked precisely',
},
editor: {
directory: 'editor-tests',
run: runEditorSuite,
description: 'A real packaged app opening a real game, checked roughly',
},
};
const loadTests = suiteName => {
const directory = path.join(__dirname, SUITES[suiteName].directory);
return fs
.readdirSync(directory)
.filter(fileName => fileName.endsWith('.js'))
.map(fileName => require(path.join(directory, fileName)))
.reduce((all, tests) => all.concat(tests), [])
.map(test => ({ ...test, suite: suiteName }));
};
const describeTest = test =>
test.monkey
? ` (${test.monkey.seeds.length} random sessions of ${
test.monkey.steps
} manipulations)`
: test.steps
? ` (${test.steps.length} manipulations)`
: '';
const main = async () => {
const args = minimist(process.argv.slice(2));
const suiteNames =
!args.suite || args.suite === 'all'
? Object.keys(SUITES)
: String(args.suite).split(',');
suiteNames.forEach(suiteName => {
if (!SUITES[suiteName])
throw new Error(
`Unknown suite "${suiteName}" (known: ${Object.keys(SUITES).join(
', '
)})`
);
});
// The tests asked for: by name, by what the changes touch, or from a file
// (what `circleci tests split` gives to each parallel container).
const namesFromFile = args['tests-file']
? fs
.readFileSync(String(args['tests-file']), 'utf8')
.split(/\s+/)
.filter(Boolean)
: null;
const changedFiles = args['only-changed']
? getChangedFiles(String(args['base-ref'] || 'origin/master'))
: null;
const getTestsOf = (suiteName, log) => {
let tests = loadTests(suiteName);
if (args.test)
tests = tests.filter(test => test.name.includes(String(args.test)));
if (namesFromFile)
tests = tests.filter(test => namesFromFile.includes(test.name));
if (args['only-changed'])
tests = filterTestsForChangedFiles({ tests, changedFiles, log });
return tests;
};
// `--list-names` prints one name per line, to be split across containers.
if (args['list-names']) {
suiteNames.forEach(suiteName =>
getTestsOf(suiteName).forEach(test => console.log(test.name))
);
return true;
}
if (args.list) {
suiteNames.forEach(suiteName => {
console.log(`\n${suiteName} — ${SUITES[suiteName].description}`);
getTestsOf(suiteName).forEach(test =>
console.log(` ${test.name}${describeTest(test)}`)
);
});
return true;
}
const options = {
headful: !!args.headful,
verbose: !!args.verbose,
chromePath: args['chrome-path'] || null,
storybookUrl: args['storybook-url'] || null,
storybookPort: Number(args['storybook-port'] || 9010),
rebuildStorybook: !!args['rebuild-storybook'],
gdevelopZipPath: args['gdevelop-zip']
? path.resolve(String(args['gdevelop-zip']))
: null,
gdevelopBranch: args['gdevelop-branch'] || 'master',
editorMonkeySteps: Number(args['editor-monkey-steps'] || 30),
workDirectory: path.resolve(String(args['work-dir'] || './work')),
};
let allPassed = true;
for (const suiteName of suiteNames) {
const reporter = makeReporter({
artifactsDirectory: path.resolve(
String(args['artifacts-dir'] || './artifacts'),
suiteName
),
logFileName: `${suiteName}-visual-tests.log`,
junitPath: args['junit-path'] ? String(args['junit-path']) : null,
suiteName,
});
const tests = getTestsOf(suiteName, reporter.log);
reporter.log(
`Running ${tests.length} test(s) of the "${suiteName}" suite ` +
`(${SUITES[suiteName].description}).`
);
if (!tests.length) {
reporter.log('No test to run.');
continue;
}
await SUITES[suiteName].run({ tests, options, reporter });
allPassed = reporter.writeSummary() && allPassed;
}
return allPassed;
};
main().then(
allPassed => process.exit(allPassed ? 0 : 1),
error => {
console.error(error && error.stack ? error.stack : error);
process.exit(2);
}
);
@@ -0,0 +1,373 @@
// @ts-check
/**
* Visual monkey tests of the Sprite object editor, run on the
* `ObjectEditor/SpriteEditorManipulations` stories - where every manipulation
* the editor offers can be done, and where the animations of the edited object
* are exposed so that what the editor displays can be compared with what the
* object actually contains.
*/
const spriteEditor = require('../helpers/SpriteEditor');
const stories = {
manipulations: 'objecteditor-spriteeditormanipulations--manipulations',
serialized:
'objecteditor-spriteeditormanipulations--manipulations-with-serialization',
manyAnimations:
'objecteditor-spriteeditormanipulations--manipulations-with-many-animations',
manyFrames:
'objecteditor-spriteeditormanipulations--manipulations-with-many-frames',
emptyObject:
'objecteditor-spriteeditormanipulations--manipulations-from-empty-object',
lockedList:
'objecteditor-spriteeditormanipulations--manipulations-with-locked-animation-list',
hostileExternalEditor:
'objecteditor-spriteeditormanipulations--manipulations-with-hostile-external-editor',
};
module.exports = [
{
helper: spriteEditor,
name: 'sprite-editor/add-animations-and-interact',
description:
'Adding animations reallocates the vector holding them: everything ' +
'resolved before must not be used afterwards.',
story: stories.serialized,
steps: [
['addAnimation'],
['addAnimation'],
['addAnimation'],
['serialize'],
['selectFrames', { row: 0, frames: [0, 1] }],
['selectFrames', { row: 1, frames: [2] }],
['serialize'],
['renameAnimation', { row: 0, name: 'Player walking to the right' }],
['addFrames', { row: 0 }],
['serialize'],
['scrollList', { delta: 600 }],
['scrollList', { delta: -600 }],
],
},
{
helper: spriteEditor,
name: 'sprite-editor/delete-animations',
description:
'Removing an animation frees its directions and shifts the following ' +
'animations, down to the last one.',
story: stories.serialized,
steps: [
['selectFrames', { row: 1, frames: [0, 1] }],
['deleteAnimation', { row: 0 }],
['serialize'],
['selectFrames', { row: 0, frames: [0] }],
['deleteAnimation', { row: 2 }],
['serialize'],
['addFrames', { row: 0 }],
['deleteAnimation', { row: 0 }],
['deleteAnimation', { row: 0 }],
['deleteAnimation', { row: 0 }],
['deleteAnimation', { row: 0 }],
['deleteAnimation', { row: 0 }],
['serialize'],
],
},
{
helper: spriteEditor,
name: 'sprite-editor/move-animations-with-the-menu',
story: stories.serialized,
steps: [
['selectFrames', { row: 0, frames: [0, 1] }],
['moveAnimationWithMenu', { row: 0, item: 'Move to bottom' }],
['serialize'],
['selectFrames', { row: 0, frames: [0] }],
['moveAnimationWithMenu', { row: 5, item: 'Move to top' }],
['serialize'],
['moveAnimationWithMenu', { row: 2, item: 'Move to position 3' }],
['addAnimation'],
['addAnimation'],
['addAnimation'],
['moveAnimationWithMenu', { row: 0, item: 'Move to bottom' }],
['serialize'],
],
},
{
helper: spriteEditor,
name: 'sprite-editor/drag-and-drop-animations',
story: stories.manipulations,
steps: [
['dragAnimation', { from: 0, to: 2 }],
['dragAnimation', { from: 2, to: 0 }],
['selectFrames', { row: 0, frames: [0, 1] }],
['dragAnimation', { from: 0, to: 2 }],
['addAnimation'],
['addAnimation'],
['addAnimation'],
['dragAnimation', { from: 1, to: 0 }],
],
},
{
helper: spriteEditor,
name: 'sprite-editor/frames-selection-and-context-menu',
description:
'Deleting, duplicating and moving the selected frames must act on ' +
'exactly the frames the user selected.',
story: stories.manyFrames,
steps: [
['selectFrames', { row: 0, frames: [1, 2, 3] }],
['frameContextMenuAction', { row: 0, frame: 1, item: 'Move to end' }],
['selectFrames', { row: 0, frames: [0, 1] }],
[
'frameContextMenuAction',
{ row: 0, frame: 0, item: 'Duplicate selection' },
],
['selectFrames', { row: 0, frames: [5, 6] }],
[
'frameContextMenuAction',
{ row: 0, frame: 5, item: 'Move to beginning' },
],
['selectFrames', { row: 0, frames: [2, 3] }],
[
'frameContextMenuAction',
{ row: 0, frame: 2, item: 'Position 4', isSubmenuItem: true },
],
['selectFrames', { row: 0, frames: [0, 1, 2] }],
[
'frameContextMenuAction',
{ row: 0, frame: 0, item: 'Delete selection' },
],
['addFrames', { row: 0 }],
['selectFrames', { row: 0, frames: [0] }],
[
'frameContextMenuAction',
{ row: 0, frame: 0, item: 'Delete selection' },
],
],
},
{
helper: spriteEditor,
name: 'sprite-editor/selection-after-the-animations-changed',
description:
'The selection designates frames by their index, so it must be reset ' +
'when the animations are changed from outside the sprites list.',
story: stories.serialized,
steps: [
['selectFrames', { row: 0, frames: [0, 1] }],
['moveAnimationWithMenu', { row: 0, item: 'Move to bottom' }],
['selectFrames', { row: 0, frames: [1] }],
[
'frameContextMenuAction',
{ row: 0, frame: 1, item: 'Delete selection' },
],
['selectFrames', { row: 1, frames: [0, 2] }],
['deleteAnimation', { row: 0 }],
['selectFrames', { row: 0, frames: [0] }],
[
'frameContextMenuAction',
{ row: 0, frame: 0, item: 'Duplicate selection' },
],
['selectFrames', { row: 0, frames: [0, 1] }],
['addAnimation'],
[
'frameContextMenuAction',
{ row: 0, frame: 0, item: 'Delete selection' },
],
],
},
{
helper: spriteEditor,
name: 'sprite-editor/drag-and-drop-frames',
story: stories.manyFrames,
steps: [
['dragFrame', { row: 0, from: 0, to: 3 }],
['dragFrame', { row: 0, from: 4, to: 1 }],
['selectFrames', { row: 0, frames: [2] }],
['dragFrame', { row: 0, from: 2, to: 5 }],
['addFrames', { row: 0 }],
['dragFrame', { row: 0, from: 1, to: 6 }],
],
},
{
helper: spriteEditor,
name: 'sprite-editor/add-frames-and-import-animations',
story: stories.manipulations,
steps: [
['addFrames', { row: 0 }],
['addFrames', { row: 0 }],
['addAnimation'],
['addAnimation'],
['addAnimation'],
['importAnimationsInEmptyAnimation'],
['selectFrames', { row: 0, frames: [0, 1] }],
['addFrames', { row: 1 }],
['scrollList', { delta: 900 }],
['scrollList', { delta: -1800 }],
],
},
{
helper: spriteEditor,
name: 'sprite-editor/external-editor',
story: stories.manipulations,
steps: [
['editWithExternalEditor', { row: 0 }],
['selectFrames', { row: 0, frames: [0, 1] }],
['editWithExternalEditor', { row: 1 }],
['addAnimation'],
['addAnimation'],
['addAnimation'],
['editWithExternalEditor', { row: 2 }],
],
},
{
helper: spriteEditor,
name: 'sprite-editor/external-editor-changing-the-animations',
description:
'The animations are changed (and their vector reallocated) while the ' +
'external editor session is running.',
story: stories.hostileExternalEditor,
steps: [
['selectFrames', { row: 0, frames: [0, 1] }],
['editWithExternalEditor', { row: 3 }],
['editWithExternalEditor', { row: 0 }],
['editWithExternalEditor', { row: 5 }],
['scrollList', { delta: 900 }],
['scrollList', { delta: -900 }],
],
},
{
helper: spriteEditor,
name: 'sprite-editor/object-dialogs',
story: stories.manipulations,
steps: [
['openPointsEditor'],
['openCollisionMasksEditor'],
['openAdvancedOptions'],
['addAnimation'],
['addAnimation'],
['addAnimation'],
['openPointsEditor'],
['selectFrames', { row: 0, frames: [0] }],
['openPreview', { row: 0 }],
['openCollisionMasksEditor'],
],
},
{
helper: spriteEditor,
name: 'sprite-editor/direction-settings',
story: stories.manipulations,
steps: [
['setTimeBetweenFrames', { row: 0, value: '0.2' }],
['toggleLoop', { row: 0 }],
['openPreview', { row: 0 }],
['addAnimation'],
['addAnimation'],
['addAnimation'],
['setTimeBetweenFrames', { row: 1, value: '0.05' }],
['toggleLoop', { row: 1 }],
['selectFrames', { row: 0, frames: [0] }],
['openPreview', { row: 0 }],
],
},
{
helper: spriteEditor,
name: 'sprite-editor/lazily-mounted-animations',
description:
'With many animations, the sprites lists are only mounted when they ' +
'are scrolled into view - after the animations may have changed.',
story: stories.manyAnimations,
steps: [
['addAnimation'],
['scrollList', { delta: 4000 }],
['scrollList', { delta: -2000 }],
['deleteAnimation', { row: 0 }],
['scrollList', { delta: 3000 }],
['scrollList', { delta: -6000 }],
['moveAnimationWithMenu', { row: 0, item: 'Move to bottom' }],
['scrollList', { delta: 6000 }],
['scrollList', { delta: -6000 }],
['addFrames', { row: 0 }],
['scrollList', { delta: 2500 }],
['scrollList', { delta: -2500 }],
],
},
{
helper: spriteEditor,
name: 'sprite-editor/locked-animation-list',
description:
'Like the editor of a child object of a custom object: only the frames ' +
'of each animation can be manipulated.',
story: stories.lockedList,
steps: [
['selectFrames', { row: 0, frames: [0, 2] }],
['frameContextMenuAction', { row: 0, frame: 0, item: 'Move to end' }],
['selectFrames', { row: 1, frames: [1] }],
[
'frameContextMenuAction',
{ row: 1, frame: 1, item: 'Duplicate selection' },
],
['addFrames', { row: 2 }],
['selectFrames', { row: 2, frames: [0, 1] }],
[
'frameContextMenuAction',
{ row: 2, frame: 0, item: 'Delete selection' },
],
['dragFrame', { row: 0, from: 0, to: 2 }],
['setTimeBetweenFrames', { row: 0, value: '0.3' }],
['editWithExternalEditor', { row: 1 }],
],
},
{
helper: spriteEditor,
name: 'sprite-editor/empty-object',
story: stories.emptyObject,
steps: [
['importImagesFromPlaceholder'],
['addFrames', { row: 0 }],
['addAnimation'],
['deleteAnimation', { row: 0 }],
['deleteAnimation', { row: 0 }],
['deleteAnimation', { row: 0 }],
['importImagesFromPlaceholder'],
['selectFrames', { row: 0, frames: [0] }],
[
'frameContextMenuAction',
{ row: 0, frame: 0, item: 'Delete selection' },
],
],
},
// The random sessions: they do what the scripted tests above do not think of.
{
helper: spriteEditor,
name: 'sprite-editor/monkey-on-a-serialized-object',
description:
'Random manipulations on an object serialized on every change, so that ' +
'the memory freed by a change is reused right away.',
story: stories.serialized,
monkey: { seeds: [1, 2, 3], steps: 60 },
},
{
helper: spriteEditor,
name: 'sprite-editor/monkey-with-many-animations',
story: stories.manyAnimations,
monkey: { seeds: [1, 2], steps: 60 },
},
{
helper: spriteEditor,
name: 'sprite-editor/monkey-with-many-frames',
story: stories.manyFrames,
monkey: { seeds: [1, 2], steps: 60 },
},
{
helper: spriteEditor,
name: 'sprite-editor/monkey-on-a-locked-animation-list',
story: stories.lockedList,
monkey: { seeds: [1, 2], steps: 60 },
},
{
helper: spriteEditor,
name: 'sprite-editor/monkey-from-an-empty-object',
story: stories.emptyObject,
monkey: { seeds: [1, 2], steps: 50 },
},
];