mirror of
https://github.com/Heretek-AI/GDevelop-Heretek.git
synced 2026-10-11 21:09:52 +00:00
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:
+381
-4
@@ -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.*/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
artifacts/
|
||||
work/
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Generated
+1263
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user