diff --git a/.gitignore b/.gitignore
index 29efead2e..bcd7b7cb9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,7 +16,6 @@ yarn-error.log*
# Editor directories and files
.idea
-.vscode
*.suo
*.ntvs*
*.njsproj
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 000000000..86d085f84
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,7 @@
+{
+ "recommendations": [
+ "vue.volar",
+ "esbenp.prettier-vscode",
+ "dbaeumer.vscode-eslint"
+ ]
+}
\ No newline at end of file
diff --git a/jsconfig.json b/jsconfig.json
new file mode 100644
index 000000000..40608e163
--- /dev/null
+++ b/jsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extensions": [
+ ".vue"
+ ],
+ "vueCompilerOptions": {
+ "target": 2
+ }
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index c4f597752..a0967b67e 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"electron:build": "vue-cli-service electron:build",
"electron:serve": "vue-cli-service electron:serve",
"postinstall": "electron-builder install-app-deps && patch-package",
- "postuninstall": "electron-builder install-app-deps"
+ "postuninstall": "electron-builder install-app-deps",
+ "test:e2e": "npx playwright test"
},
"main": "background.js",
"dependencies": {
@@ -32,7 +33,6 @@
"dotenv": "^8.2.0",
"electron-updater": "^4.3.1",
"fluent-ffmpeg": "^2.1.2",
- "font-list": "^1.3.1",
"fuse.js": "^6.2.1",
"golden-layout": "^1.5.9",
"grandiose": "github:vcync/grandiose#feat/workerCompatibility",
@@ -70,21 +70,26 @@
"@babel/core": "^7.0.0-0",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.0",
"@babel/plugin-proposal-optional-chaining": "^7.16.0",
+ "@playwright/test": "^1.31.2",
"@semantic-release/git": "^9.0.0",
"@vue/cli-plugin-babel": "^4.5.15",
"@vue/cli-plugin-eslint": "^3.12.1",
"@vue/cli-service": "^3.12.1",
"@vue/eslint-config-prettier": "^4.0.1",
+ "@vue/runtime-dom": "^3.2.47",
"babel-eslint": "^10.0.3",
"core-js": "^3.19.1",
"electron": "^23.1.2",
"electron-builder": "^22.9.1",
"electron-notarize": "^1.2.2",
+ "electron-playwright-helpers": "^1.5.3",
"eslint": "^5.16.0",
"eslint-plugin-no-for-each": "^0.1.14",
"eslint-plugin-vue": "^5.2.3",
"lint-staged": "^8.2.1",
"node-loader": "^0.6.0",
+ "playwright": "^1.31.2",
+ "playwright-core": "^1.31.2",
"sass-loader": "^7.3.1",
"text-loader": "0.0.1",
"vue-cli-plugin-electron-builder": "^2.0.0",
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 000000000..691eac234
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,9 @@
+import { defineConfig, expect } from "@playwright/test";
+import extensions from "./tests/e2e/extentions";
+
+expect.extend(extensions);
+
+export default defineConfig({
+ testDir: "./tests/e2e/spec",
+ workers: process.env.CI ? 1 : 2
+});
diff --git a/src/App.vue b/src/App.vue
index 1bd2b01c6..cec59cad4 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -142,20 +142,11 @@ export default {
state: null,
layoutState: null,
- showUi: true,
- mouseTimer: null,
- cursor: "none",
triggerUiRestart: 0
};
},
computed: {
- pluginComponents() {
- return this.$modV.store.state.plugins
- .filter(plugin => "component" in plugin)
- .map(plugin => plugin.component.name);
- },
-
focusedModules() {
const focusedOrPinned = this.$store.getters["ui-modules/focusedOrPinned"];
const modules = focusedOrPinned.map(
@@ -191,50 +182,6 @@ export default {
},
methods: {
- makeFullScreen() {
- if (!document.body.ownerDocument.webkitFullscreenElement) {
- document.body.webkitRequestFullscreen();
- } else {
- document.body.ownerDocument.webkitExitFullscreen();
- }
- },
-
- mouseMove() {
- if (this.mouseTimer) {
- clearTimeout(this.mouseTimer);
- }
-
- this.cursor = "default";
- this.mouseTimer = setTimeout(this.movedMouse, 200);
- },
-
- movedMouse() {
- if (this.mouseTimer) {
- this.mouseTimer = null;
- }
-
- this.cursor = "none";
- },
-
- getProps(moduleName) {
- const moduleDefinition = this.$modV.store.state.modules.registered[
- moduleName
- ];
-
- return Object.keys(moduleDefinition.props).filter(
- key =>
- moduleDefinition.props[key].type === "int" ||
- moduleDefinition.props[key].type === "float" ||
- moduleDefinition.props[key].type === "text" ||
- moduleDefinition.props[key].type === "bool" ||
- moduleDefinition.props[key].type === "color" ||
- moduleDefinition.props[key].type === "vec2" ||
- moduleDefinition.props[key].type === "tween" ||
- moduleDefinition.props[key].type === "texture" ||
- moduleDefinition.props[key].type === "enum"
- );
- },
-
toggleModulePin(id) {
if (this.isPinned(id)) {
this.$store.commit("ui-modules/REMOVE_PINNED", id);
diff --git a/src/application/index.js b/src/application/index.js
index bf60b0d89..443a72587 100644
--- a/src/application/index.js
+++ b/src/application/index.js
@@ -1,3 +1,9 @@
+import PromiseWorker from "promise-worker-transferable";
+import Vue from "vue";
+import { ipcRenderer } from "electron";
+import { app } from "@electron/remote";
+import { createWebcodecVideo } from "./createWebcodecVideo";
+
import Worker from "worker-loader!./worker/index.worker.js";
import {
setupMedia,
@@ -7,16 +13,9 @@ import {
} from "./setup-media";
import setupBeatDetektor from "./setup-beat-detektor";
import setupMidi from "./setup-midi";
-
import store from "./worker/store";
import windowHandler from "./window-handler";
import use from "./use";
-
-import PromiseWorker from "promise-worker-transferable";
-import Vue from "vue";
-import { ipcRenderer } from "electron";
-import { app } from "@electron/remote";
-import { createWebcodecVideo } from "./createWebcodecVideo";
import { GROUP_ENABLED } from "./constants";
let imageBitmap;
@@ -158,6 +157,8 @@ export default class ModV {
async setup(canvas = document.createElement("canvas")) {
this.windowHandler();
+ this.enumerateFonts();
+
try {
await this.setupMedia({ useDefaultDevices: true });
} catch (e) {
@@ -310,4 +311,21 @@ export default class ModV {
loadPreset(filePathToPreset) {
this.$worker.postMessage({ type: "loadPreset", payload: filePathToPreset });
}
+
+ async enumerateFonts() {
+ const localFonts = await window.queryLocalFonts();
+ const fonts = [];
+
+ for (let i = 0; i < localFonts.length; i += 1) {
+ const { family, fullName, postscriptName, style } = localFonts[i];
+
+ fonts.push({ family, fullName, postscriptName, style });
+
+ // No need to await here, async loading is fine.
+ // The user can't use fonts fonts immediately at this stage, so no need to block the thread
+ document.fonts.load(`14px ${postscriptName}`, fullName);
+ }
+
+ this.store.commit("fonts/SET_LOCAL_FONTS", fonts);
+ }
}
diff --git a/src/application/utils/apply-expression.js b/src/application/utils/apply-expression.js
index 7b6f3d37a..b3cb08566 100644
--- a/src/application/utils/apply-expression.js
+++ b/src/application/utils/apply-expression.js
@@ -1,3 +1,4 @@
+import get from "lodash.get";
import store from "../worker/store";
export function applyExpression({ value, inputId }) {
@@ -5,12 +6,15 @@ export function applyExpression({ value, inputId }) {
inputId
);
+ const input = store.state.inputs.inputs[inputId];
+
let dataOut = value;
if (expressionAssignment) {
const scope = {
value: dataOut,
- time: Date.now()
+ time: Date.now(),
+ inputValue: get(store.state, input.getLocation)
};
dataOut = expressionAssignment.func.evaluate(scope);
diff --git a/src/application/window-handler.js b/src/application/window-handler.js
index ffea8834e..39dfd17b6 100644
--- a/src/application/window-handler.js
+++ b/src/application/window-handler.js
@@ -1,5 +1,6 @@
export default function windowHandler() {
const windows = {};
+ const that = this;
function createHideMouseTimerhandler(canvas) {
let mouseTimer;
@@ -18,10 +19,16 @@ export default function windowHandler() {
};
}
- function configureWindow({ win, canvas, title, backgroundColor }) {
+ function configureWindow({ win, canvas, backgroundColor }) {
win.document.body.appendChild(canvas);
- win.document.title = title;
win.document.body.style.backgroundColor = backgroundColor;
+ win.addEventListener("beforeunload", ev => {
+ // Setting any value other than undefined here will prevent the window
+ // from closing or reloading
+ ev.returnValue = true;
+ });
+
+ setSize.call(that, win);
}
function pollToConfigureWindow(args) {
@@ -62,6 +69,7 @@ export default function windowHandler() {
"modal",
`width=${width}, height=${height}, location=no, menubar=no, left=0`
);
+ win.document.title = title;
if (win === null || typeof win === "undefined") {
console.log(
@@ -120,8 +128,6 @@ export default function windowHandler() {
setSize.call(this, win);
});
});
-
- setSize.call(this, win);
}
});
diff --git a/src/application/worker/index.worker.js b/src/application/worker/index.worker.js
index 96bcd6c36..9b52ead43 100644
--- a/src/application/worker/index.worker.js
+++ b/src/application/worker/index.worker.js
@@ -11,6 +11,9 @@ async function start() {
const grabCanvasPlugin = require("../plugins/grab-canvas").default;
const get = require("lodash.get");
+ // For Playwright
+ self._get = get;
+
const { tick: frameTick } = require("./frame-counter");
const { getFeatures, setFeatures } = require("./audio-features");
// const featureAssignmentPlugin = require("../plugins/feature-assignment");
@@ -344,6 +347,7 @@ async function start() {
store.commit("groups/SWAP");
store.commit("modules/SWAP");
store.commit("inputs/SWAP");
+ store.commit("expressions/SWAP");
return;
}
diff --git a/src/application/worker/store/modules/common/swap.js b/src/application/worker/store/modules/common/swap.js
index 667aa3674..9eed32aa5 100644
--- a/src/application/worker/store/modules/common/swap.js
+++ b/src/application/worker/store/modules/common/swap.js
@@ -7,7 +7,7 @@ import Vue from "vue";
* The idea is that this makes loading presets smooth and the end user will not see any
* glitches in the render loop.
*/
-export default function SWAP(swap, getDefault, sharedPropertyRestrictions) {
+export function SWAP(swap, getDefault, sharedPropertyRestrictions) {
return function(state) {
const stateKeys = Object.keys(state);
@@ -82,8 +82,6 @@ export default function SWAP(swap, getDefault, sharedPropertyRestrictions) {
}
}
});
- } else {
- Object.assign(swap, getDefault());
}
Object.assign(swap, getDefault());
diff --git a/src/application/worker/store/modules/dataTypes.js b/src/application/worker/store/modules/dataTypes.js
index aeb98e577..7938e5360 100644
--- a/src/application/worker/store/modules/dataTypes.js
+++ b/src/application/worker/store/modules/dataTypes.js
@@ -2,6 +2,9 @@ import store from "../";
import { frames, advanceFrame } from "./tweens";
const state = {
+ text: {
+ get: value => value
+ },
int: {
get: value => value
},
@@ -122,6 +125,10 @@ const state = {
}
};
+const getters = {
+ types: state => Object.keys(state)
+};
+
const actions = {
createType({ state }, { type, args }) {
return state[type].create(args);
@@ -131,5 +138,6 @@ const actions = {
export default {
namespaced: true,
state,
+ getters,
actions
};
diff --git a/src/application/worker/store/modules/expressions.js b/src/application/worker/store/modules/expressions.js
index 6bfc2fbe3..cfca79b47 100644
--- a/src/application/worker/store/modules/expressions.js
+++ b/src/application/worker/store/modules/expressions.js
@@ -1,9 +1,16 @@
+import get from "lodash.get";
import { v4 as uuidv4 } from "uuid";
+import { SWAP } from "./common/swap";
const math = require("mathjs");
-const state = {
- assignments: {}
-};
+function getDefaultState() {
+ return {
+ assignments: {}
+ };
+}
+
+const state = getDefaultState();
+const swap = getDefaultState();
// getters
const getters = {
@@ -14,8 +21,8 @@ const getters = {
}
};
-function compileExpression(expression) {
- const scope = { value: 0, time: 0 };
+function compileExpression(expression, scopeItems = {}) {
+ const scope = { value: 0, time: 0, ...scopeItems };
let newFunction;
try {
@@ -32,7 +39,10 @@ function compileExpression(expression) {
// actions
const actions = {
- create({ commit }, { expression = "value", id, inputId }) {
+ create(
+ { rootState, commit },
+ { expression = "value", id, inputId, writeToSwap }
+ ) {
if (!inputId) {
throw new Error("Input ID required");
}
@@ -43,7 +53,15 @@ const actions = {
const expressionId = id || uuidv4();
- const func = compileExpression(expression);
+ const input = rootState.inputs.inputs[inputId];
+
+ const func = compileExpression(expression, {
+ // We currrently have no way of interacting with swap state.
+ // This would be something to fix in the future, maybe use an entire store
+ // for swap, or write a more specific mechanism to look up values in swap
+ // state.
+ inputValue: writeToSwap ? 0 : get(rootState, input.getLocation)
+ });
if (!func) {
throw new Error("Unable to compile Expression");
@@ -56,12 +74,12 @@ const actions = {
expression
};
- commit("ADD_EXPRESSION", { assignment });
+ commit("ADD_EXPRESSION", { assignment, writeToSwap });
return expressionId;
},
- update({ commit }, { id, expression = "value" }) {
+ update({ rootState, commit }, { id, expression = "value", writeToSwap }) {
if (!id) {
throw new Error("Expression ID required");
}
@@ -77,7 +95,11 @@ const actions = {
return null;
}
- const func = compileExpression(expression);
+ const input = rootState.inputs.inputs[existingExpression.inputId];
+
+ const func = compileExpression(expression, {
+ inputValue: get(rootState, input.getLocation)
+ });
if (!func) {
throw new Error("Unable to compile Expression");
@@ -86,7 +108,7 @@ const actions = {
existingExpression.func = func;
existingExpression.expression = expression;
- commit("ADD_EXPRESSION", { assignment: existingExpression });
+ commit("ADD_EXPRESSION", { assignment: existingExpression, writeToSwap });
return existingExpression.id;
},
@@ -103,20 +125,23 @@ const actions = {
for (let i = 0, len = assignments.length; i < len; i++) {
const assignment = assignments[i];
- await dispatch("create", assignment);
+ await dispatch("create", { ...assignment, writeToSwap: true });
}
}
};
// mutations
const mutations = {
- ADD_EXPRESSION(state, { assignment }) {
- state.assignments[assignment.id] = assignment;
+ ADD_EXPRESSION(state, { assignment, writeToSwap = false }) {
+ const writeTo = writeToSwap ? swap : state;
+ writeTo.assignments[assignment.id] = assignment;
},
REMOVE_EXPRESSION(state, { id }) {
delete state.assignments[id];
- }
+ },
+
+ SWAP: SWAP(swap, getDefaultState)
};
export default {
diff --git a/src/application/worker/store/modules/fonts.js b/src/application/worker/store/modules/fonts.js
new file mode 100644
index 000000000..0b2079d14
--- /dev/null
+++ b/src/application/worker/store/modules/fonts.js
@@ -0,0 +1,30 @@
+const state = {
+ defaultFonts: ["serif", "sans-serif", "cursive", "monospace"],
+ localFonts: []
+};
+
+const getters = {
+ fonts: state => [
+ ...state.defaultFonts,
+ ...state.localFonts
+ .filter(
+ (value, index, self) =>
+ index === self.findIndex(t => t.family === value.family)
+ )
+ .map(font => font.family)
+ .sort((a, b) => a.localeCompare(b))
+ ]
+};
+
+const mutations = {
+ SET_LOCAL_FONTS(state, fonts = []) {
+ state.localFonts = fonts;
+ }
+};
+
+export default {
+ namespaced: true,
+ state,
+ getters,
+ mutations
+};
diff --git a/src/application/worker/store/modules/groups.js b/src/application/worker/store/modules/groups.js
index db7698a62..d480ecb29 100644
--- a/src/application/worker/store/modules/groups.js
+++ b/src/application/worker/store/modules/groups.js
@@ -1,4 +1,4 @@
-import SWAP from "./common/swap";
+import { SWAP } from "./common/swap";
import store from "../";
import constants, { GROUP_DISABLED } from "../../../constants";
import { v4 as uuidv4 } from "uuid";
diff --git a/src/application/worker/store/modules/inputs.js b/src/application/worker/store/modules/inputs.js
index 62ebf43e8..bc4dd8fed 100644
--- a/src/application/worker/store/modules/inputs.js
+++ b/src/application/worker/store/modules/inputs.js
@@ -1,6 +1,6 @@
import Vue from "vue";
import { v4 as uuidv4 } from "uuid";
-import SWAP from "./common/swap";
+import { SWAP } from "./common/swap";
/**
* InputLinkType enum string values.
@@ -99,8 +99,11 @@ const actions = {
commit("SET_FOCUSED_INPUT", { id: null, title: null });
},
- addInput({ commit }, { type, location, data, id = uuidv4(), writeToSwap }) {
- const input = { type, location, data, id };
+ addInput(
+ { commit },
+ { type, getLocation, location, data, id = uuidv4(), writeToSwap }
+ ) {
+ const input = { type, getLocation, location, data, id };
commit("ADD_INPUT", { input, writeToSwap });
return input;
},
diff --git a/src/application/worker/store/modules/modules.js b/src/application/worker/store/modules/modules.js
index a5c30a66e..580ed463d 100644
--- a/src/application/worker/store/modules/modules.js
+++ b/src/application/worker/store/modules/modules.js
@@ -1,5 +1,5 @@
import Vue from "vue";
-import SWAP from "./common/swap";
+import { SWAP } from "./common/swap";
import getNextName from "../../../utils/get-next-name";
import getPropDefault from "../../../utils/get-prop-default";
import store from "..";
@@ -80,6 +80,7 @@ async function initialiseModuleProperties(
) {
const inputBind = await store.dispatch("inputs/addInput", {
type: "action",
+ getLocation: `modules.active["${module.$id}"].props["${propKey}"]`,
location: "modules/updateProp",
data: { moduleId: module.$id, prop: propKey },
writeToSwap
@@ -96,6 +97,7 @@ async function initialiseModuleProperties(
const key = dataTypeInputsKeys[i];
await store.dispatch("inputs/addInput", {
type: "action",
+ getLocation: `modules.active["${module.$id}"].props["${propKey}"]["${key}"]`,
location: "modules/updateProp",
data: {
moduleId: module.$id,
@@ -288,6 +290,7 @@ const actions = {
if (!moduleMeta.isGallery) {
const alphaInputBind = await store.dispatch("inputs/addInput", {
type: "action",
+ getLocation: `modules.active["${module.$id}"].meta.alpha`,
location: "modules/updateMeta",
data: { id: module.$id, metaKey: "alpha" }
});
@@ -296,6 +299,7 @@ const actions = {
const enabledInputBind = await store.dispatch("inputs/addInput", {
type: "action",
+ getLocation: `modules.active["${module.$id}"].meta.enabled`,
location: "modules/updateMeta",
data: { id: module.$id, metaKey: "enabled" }
});
@@ -304,6 +308,7 @@ const actions = {
const coInputBind = await store.dispatch("inputs/addInput", {
type: "action",
+ getLocation: `modules.active["${module.$id}"].meta.compositeOperation`,
location: "modules/updateMeta",
data: { moduleId: module.$id, metaKey: "compositeOperation" }
});
diff --git a/src/application/worker/store/modules/videos.js b/src/application/worker/store/modules/videos.js
index 8dfbcc87c..a359531ab 100644
--- a/src/application/worker/store/modules/videos.js
+++ b/src/application/worker/store/modules/videos.js
@@ -31,7 +31,7 @@ const actions = {
});
}
- commit("CREATE_VIDEO", { id, path });
+ commit("CREATE_VIDEO", { id, path: filePath });
return { id };
},
diff --git a/src/background/background.js b/src/background/background.js
index 72b795ba5..592a2562c 100644
--- a/src/background/background.js
+++ b/src/background/background.js
@@ -1,4 +1,4 @@
-import { app, protocol } from "electron";
+import { app, ipcMain, protocol } from "electron";
import { APP_SCHEME } from "./background-constants";
import { getMediaManager } from "./media-manager";
import { openFile } from "./open-file";
@@ -37,7 +37,7 @@ app.on("window-all-closed", () => {
app.on("activate", async () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
- createWindow("mainWindow");
+ createWindow({ windowName: "mainWindow" });
});
// https://stackoverflow.com/a/66673831
@@ -73,8 +73,10 @@ app.on("ready", async () => {
);
createWindow({ windowName: "mainWindow" });
- createWindow({ windowName: "splashScreen" });
- createWindow({ windowName: "colorPicker", options: { show: false } });
+ ipcMain.once("main-window-created", () => {
+ createWindow({ windowName: "splashScreen" });
+ createWindow({ windowName: "colorPicker", options: { show: false } });
+ });
});
// Exit cleanly on request from parent process in development mode.
diff --git a/src/background/menu-bar.js b/src/background/menu-bar.js
index f457caf08..f42529193 100644
--- a/src/background/menu-bar.js
+++ b/src/background/menu-bar.js
@@ -1,4 +1,5 @@
import fs from "fs";
+import path from "path";
import { dialog, shell, app, ipcMain, Menu } from "electron";
import { windows } from "./windows";
import { openFile } from "./open-file";
@@ -7,6 +8,54 @@ import { projectNames, setCurrentProject, currentProject } from "./projects";
const isMac = process.platform === "darwin";
+let lastFileSavedPath = null;
+
+async function save(filePath) {
+ let result;
+ if (!filePath) {
+ result = await dialog.showSaveDialog(windows["mainWindow"], {
+ defaultPath: lastFileSavedPath || "preset.json",
+ filters: [{ name: "Presets", extensions: ["json"] }]
+ });
+
+ if (result.canceled) {
+ return;
+ }
+ }
+
+ try {
+ await writePresetToFile(filePath ?? result.filePath);
+ lastFileSavedPath = path.resolve(filePath ?? result.filePath);
+ updateMenu();
+ } catch (e) {
+ console.error(e);
+ }
+}
+
+async function writePresetToFile(filePath) {
+ ipcMain.once("preset-data", async (_, presetData) => {
+ try {
+ await fs.promises.writeFile(filePath, presetData);
+ } catch (e) {
+ dialog.showMessageBox(windows["mainWindow"], {
+ type: "error",
+ message: "Could not save preset to file",
+ detail: e.toString()
+ });
+ }
+ });
+
+ try {
+ windows["mainWindow"].webContents.send("generate-preset");
+ } catch (e) {
+ dialog.showMessageBox(windows["mainWindow"], {
+ type: "error",
+ message: "Could not generate preset",
+ detail: e.toString()
+ });
+ }
+}
+
export function generateMenuTemplate() {
const mediaManager = getMediaManager();
@@ -70,38 +119,16 @@ export function generateMenuTemplate() {
{ type: "separator" },
{
label: "Save Preset",
+ accelerator: "CmdOrCtrl+S",
+ async click() {
+ save(lastFileSavedPath);
+ }
+ },
+ {
+ label: "Save Preset As…",
accelerator: "CmdOrCtrl+Shift+S",
async click() {
- const result = await dialog.showSaveDialog(windows["mainWindow"], {
- defaultPath: "preset.json",
- filters: [{ name: "Presets", extensions: ["json"] }]
- });
-
- if (result.canceled) {
- return;
- }
-
- ipcMain.once("preset-data", async (event, presetData) => {
- try {
- await fs.promises.writeFile(result.filePath, presetData);
- } catch (e) {
- dialog.showMessageBox(windows["mainWindow"], {
- type: "error",
- message: "Could not save preset to file",
- detail: e.toString()
- });
- }
- });
-
- try {
- windows["mainWindow"].webContents.send("generate-preset");
- } catch (e) {
- dialog.showMessageBox(windows["mainWindow"], {
- type: "error",
- message: "Could not generate preset",
- detail: e.toString()
- });
- }
+ save();
}
},
@@ -239,7 +266,14 @@ export function generateMenuTemplate() {
];
}
-export function updateMenu() {
+export function updateMenu(setWindowListener) {
+ if (setWindowListener) {
+ windows["mainWindow"].on("ready-to-show", () => {
+ lastFileSavedPath = null;
+ updateMenu();
+ });
+ }
+
const menu = Menu.buildFromTemplate(generateMenuTemplate());
Menu.setApplicationMenu(menu);
}
diff --git a/src/background/window-prefs.js b/src/background/window-prefs.js
index 43af0ed75..abed2ec64 100644
--- a/src/background/window-prefs.js
+++ b/src/background/window-prefs.js
@@ -8,8 +8,10 @@ import { closeWindow, createWindow, windows } from "./windows";
import { updateMenu } from "./menu-bar";
import { getMediaManager } from "./media-manager";
-const isDevelopment = process.env.NODE_ENV !== "production";
+const isDevelopment = process.env.NODE_ENV === "development";
+const isTest = process.env.CI === "e2e";
let shouldCloseMainWindowAndQuit = false;
+let modVReady = false;
const windowPrefs = {
colorPicker: {
@@ -65,6 +67,8 @@ const windowPrefs = {
beforeCreate() {
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
+ modVReady = false;
+
return {
options: {
width,
@@ -76,6 +80,8 @@ const windowPrefs = {
async create(window) {
require("@electron/remote/main").enable(window.webContents);
+ ipcMain.handle("is-modv-ready", () => modVReady);
+
// Configure child windows to open without a menubar (windows/linux)
window.webContents.on(
"new-window",
@@ -84,7 +90,10 @@ const windowPrefs = {
event.preventDefault();
event.newGuest = new BrowserWindow({
...options,
- autoHideMenuBar: true
+ autoHideMenuBar: true,
+ closable: false,
+ enableLargerThanScreen: true,
+ title: ""
});
event.newGuest.removeMenu();
@@ -114,6 +123,7 @@ const windowPrefs = {
});
ipcMain.on("modv-ready", () => {
+ modVReady = true;
mm.start();
});
@@ -144,7 +154,7 @@ const windowPrefs = {
window.webContents.send("input-update", message);
});
- if (!isDevelopment || process.env.IS_TEST) {
+ if (!isDevelopment && !isTest) {
window.on("close", async e => {
if (shouldCloseMainWindowAndQuit) {
app.quit();
@@ -211,16 +221,13 @@ const windowPrefs = {
unique: true,
async create(window) {
- windows["mainWindow"].maximize();
-
ipcMain.on("modv-ready", () => {
try {
window.close();
} catch (e) {
console.error(e);
}
-
- windows["mainWindow"].show();
+ windows["mainWindow"].maximize();
});
}
}
diff --git a/src/background/windows.js b/src/background/windows.js
index 7ea6f9e07..d23e911e0 100644
--- a/src/background/windows.js
+++ b/src/background/windows.js
@@ -7,8 +7,6 @@ import { windowPrefs } from "./window-prefs";
const windows = {};
function createWindow({ windowName, options = {} }, event) {
- updateMenu();
-
if (windowPrefs[windowName].unique && windows[windowName]) {
windows[windowName].focus();
windows[windowName].show();
@@ -36,6 +34,8 @@ function createWindow({ windowName, options = {} }, event) {
...options
});
+ updateMenu(true);
+
if (typeof windowPrefs[windowName].create === "function") {
windowPrefs[windowName].create(windows[windowName]);
}
diff --git a/src/components/ActiveModule.vue b/src/components/ActiveModule.vue
index 0a1adc1b6..b050e7cb3 100644
--- a/src/components/ActiveModule.vue
+++ b/src/components/ActiveModule.vue
@@ -6,8 +6,13 @@
@focus="clickActiveModule"
ref="activeModule"
:class="{ focused }"
+ :id="`active-module-${id}`"
>
-
+
{{ name }}
- moduleDefinition.props[key].type === "int" ||
- moduleDefinition.props[key].type === "float" ||
- moduleDefinition.props[key].type === "text" ||
- moduleDefinition.props[key].type === "bool" ||
- moduleDefinition.props[key].type === "color" ||
- moduleDefinition.props[key].type === "vec2" ||
- moduleDefinition.props[key].type === "tween" ||
- moduleDefinition.props[key].type === "texture" ||
- moduleDefinition.props[key].type === "enum"
- );
- },
-
focusInput(id, title) {
this.$modV.store.dispatch("inputs/setFocusedInput", {
id,
@@ -246,6 +238,16 @@ export default {
this.$emit("remove-module", this.id);
}
+ },
+
+ titleMouseDown() {
+ this.grabbing = true;
+ window.addEventListener("mouseup", this.titleMouseUp);
+ },
+
+ titleMouseUp() {
+ this.grabbing = false;
+ window.removeEventListener("mouseup", this.titleMouseUp);
}
}
};
@@ -263,12 +265,17 @@ export default {
outline: #c4c4c4 2px solid;
}
-.active-module__title {
+.active-module__name {
background: #9a9a9a;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
+ cursor: grab;
+}
+
+.active-module__name.grabbing {
+ cursor: grabbing;
}
.active-module__controls,
@@ -279,7 +286,7 @@ export default {
}
.active-module__controls grid,
-.active-module__title {
+.active-module__name {
box-sizing: border-box;
padding: 0 4px;
}
diff --git a/src/components/Control.vue b/src/components/Control.vue
index f9aa80f31..614446126 100644
--- a/src/components/Control.vue
+++ b/src/components/Control.vue
@@ -3,6 +3,7 @@
columns="4"
@mousedown="focusInput"
:class="{ 'has-link': hasLink, focused: inputIsFocused }"
+ :id="`module-control-${inputId}`"
>
@@ -67,7 +68,7 @@
-
@@ -93,6 +94,7 @@ import Vec3Control from "./Controls/Vec3Control";
import Vec4Control from "./Controls/Vec4Control";
import hasLink from "./mixins/has-input-link";
import inputIsFocused from "./mixins/input-is-focused";
+import Select from "./inputs/Select.vue";
export default {
mixins: [hasLink, inputIsFocused],
@@ -137,7 +139,8 @@ export default {
FontControl,
ColorControl,
Vec3Control,
- Vec4Control
+ Vec4Control,
+ Select
},
data() {
diff --git a/src/components/Controls/FontControl.vue b/src/components/Controls/FontControl.vue
index 91ca018fd..6f889d667 100644
--- a/src/components/Controls/FontControl.vue
+++ b/src/components/Controls/FontControl.vue
@@ -11,7 +11,7 @@
:style="{ fontFamily: value }"
/>
-
+
-
-
+
diff --git a/src/components/Controls/TweenControl.vue b/src/components/Controls/TweenControl.vue
index fd7c776e2..33630630d 100644
--- a/src/components/Controls/TweenControl.vue
+++ b/src/components/Controls/TweenControl.vue
@@ -10,7 +10,7 @@
@@ -34,7 +34,13 @@
Use BPM
-
+
@@ -91,7 +97,7 @@ export default {
modelData: "",
modelDuration: 1000,
modelEasing: "linear",
- useBpm: true,
+ modelUseBpm: true,
modelBpmDivision: 32,
modelDurationAsTotalTime: false,
modelSteps: 0
@@ -100,7 +106,7 @@ export default {
created() {
if (this.value) {
- this.modelData = JSON.stringify(this.value.data);
+ this.setDefaultData(this.value);
}
},
@@ -115,7 +121,7 @@ export default {
const data = this.modelData.length ? JSON.parse(this.modelData) : [];
const duration = this.modelDuration;
const easing = this.modelEasing;
- const useBpm = this.useBpm;
+ const useBpm = this.modelUseBpm;
const bpmDivision = this.modelBpmDivision;
const durationAsTotalTime = this.modelDurationAsTotalTime;
const steps = this.modelSteps;
@@ -130,15 +136,45 @@ export default {
durationAsTotalTime,
steps
});
+ },
+
+ setData(value) {
+ this.modelData = value.data && JSON.stringify(value.data);
+ this.modelDuration = value.duration;
+ this.modelEasing = value.easing;
+ this.modelUseBpm = value.useBpm;
+ this.modelBpmDivision = value.bpmDivision;
+ this.modelDurationAsTotalTime = value.durationAsTotalTime;
+ this.modelSteps = value.steps;
+ },
+
+ setDefaultData() {
+ this.setData({
+ data: "",
+ duration: 1000,
+ easing: "linear",
+ useBpm: true,
+ bpmDivision: 32,
+ durationAsTotalTime: false,
+ steps: 0
+ });
}
},
watch: {
"$store.state.bpm"(value) {
- if (this.useBpm) {
+ if (this.modelUseBpm) {
this.modelDuration = value / this.modelBpmDivision;
this.updateValue();
}
+ },
+
+ value(value) {
+ if (!value) {
+ this.setDefaultData();
+ } else {
+ this.setData(value);
+ }
}
}
};
diff --git a/src/components/GalleryItem.vue b/src/components/GalleryItem.vue
index a13c9b0b9..6796cfa92 100644
--- a/src/components/GalleryItem.vue
+++ b/src/components/GalleryItem.vue
@@ -3,8 +3,10 @@
@mouseover="focus"
@mouseleave="blur"
@dblclick="doubleClick"
+ @mousedown="mouseDown"
v-if="!badModule"
class="gallery-item"
+ :class="{ grabbing }"
>
{{ moduleName }}
@@ -21,7 +23,8 @@ export default {
return {
id: "",
outputId: "",
- badModule: false
+ badModule: false,
+ grabbing: false
};
},
@@ -89,6 +92,9 @@ export default {
groupId: this.groupId,
moduleId: this.id
});
+
+ // ensure listener cleanup
+ this.mouseUp();
},
methods: {
@@ -141,6 +147,16 @@ export default {
group => group.id === groupId
).modules.length
});
+ },
+
+ mouseDown() {
+ this.grabbing = true;
+ window.addEventListener("mouseup", this.mouseUp);
+ },
+
+ mouseUp() {
+ this.grabbing = false;
+ window.removeEventListener("mouseup", this.mouseUp);
}
}
};
@@ -153,13 +169,17 @@ canvas {
.gallery-item {
position: relative;
- cursor: move;
+ cursor: grab;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
+.gallery-item.grabbing {
+ cursor: grabbing;
+}
+
.gallery-item:hover canvas {
opacity: 1;
}
diff --git a/src/components/Group.vue b/src/components/Group.vue
index c47685e41..30f0804ec 100644
--- a/src/components/Group.vue
+++ b/src/components/Group.vue
@@ -1,6 +1,7 @@
@@ -72,6 +74,7 @@
@@ -91,6 +94,7 @@
@@ -108,7 +112,13 @@
>
Alpha
-
+
@@ -128,6 +138,7 @@
-
-
{{
+
+ {{
name
}}
diff --git a/src/components/InputConfig.vue b/src/components/InputConfig.vue
index e75b83900..0b84e5b01 100644
--- a/src/components/InputConfig.vue
+++ b/src/components/InputConfig.vue
@@ -20,7 +20,7 @@
{{ focusedInputTitle }}
{{ focusedInputTitle }}
@@ -196,7 +196,7 @@ grid.borders > c:not(:last-child):not(:first-child) {
border-bottom: 1px solid var(--foreground-color-2);
}
-.title {
+.input-config__title {
font-size: 24px;
}
diff --git a/src/components/InputLinkComponents/Tween.vue b/src/components/InputLinkComponents/Tween.vue
index 0d5c8a330..bc6b92da4 100644
--- a/src/components/InputLinkComponents/Tween.vue
+++ b/src/components/InputLinkComponents/Tween.vue
@@ -46,7 +46,7 @@ export default {
async makeLink() {
const tween = await this.$modV.store.dispatch("dataTypes/createType", {
type: "tween",
- args: this.localCache
+ args: { id: this.inputId, ...this.localCache }
});
this.hasLink = await this.$modV.store.dispatch("inputs/createInputLink", {
@@ -62,6 +62,16 @@ export default {
inputId: this.inputId
});
}
+ },
+
+ watch: {
+ inputId() {
+ if (this.hasLink) {
+ this.localCache = this.$modV.store.state.tweens.tweens[this.inputId];
+ } else {
+ this.localCache = null;
+ }
+ }
}
};
diff --git a/src/components/ModuleInspector.vue b/src/components/ModuleInspector.vue
index 04b2a0359..94300c8fd 100644
--- a/src/components/ModuleInspector.vue
+++ b/src/components/ModuleInspector.vue
@@ -1,12 +1,16 @@
-
+
- {{ module.meta.name }}
+ {{ module.meta.name }}
- moduleDefinition.props[key].type === "int" ||
- moduleDefinition.props[key].type === "float" ||
- moduleDefinition.props[key].type === "text" ||
- moduleDefinition.props[key].type === "bool" ||
- moduleDefinition.props[key].type === "color" ||
- moduleDefinition.props[key].type === "vec2" ||
- moduleDefinition.props[key].type === "vec3" ||
- moduleDefinition.props[key].type === "vec4" ||
- moduleDefinition.props[key].type === "tween" ||
- moduleDefinition.props[key].type === "texture" ||
- moduleDefinition.props[key].type === "enum"
+ return Object.keys(moduleDefinition.props).filter(key =>
+ this.$modV.store.getters["dataTypes/types"].includes(
+ moduleDefinition.props[key].type
+ )
);
}
}
@@ -99,7 +94,7 @@ grid {
margin: -8px;
}
-.title {
+.module-inspector__title {
font-size: 24px;
padding: 8px;
}
diff --git a/src/main.js b/src/main.js
index 48f736846..fa2edaae8 100644
--- a/src/main.js
+++ b/src/main.js
@@ -11,6 +11,8 @@ import App from "./App.vue";
import ModV from "./application";
import store from "./ui-store";
import contextMenuPlugin from "./application/plugins/context-menu";
+import { ipcRenderer } from "electron";
+import get from "lodash.get";
Vue.config.ignoredElements = ["grid", "c"];
Vue.config.productionTip = false;
@@ -34,7 +36,11 @@ window.Vue = new Vue({
store
});
+// For Playwright
+window._get = get;
+
async function start() {
+ ipcRenderer.send("main-window-created");
const loadingElement = document.getElementById("loading");
// eslint-disable-next-line no-for-each/no-for-each
diff --git a/tests/e2e/extentions/index.js b/tests/e2e/extentions/index.js
new file mode 100644
index 000000000..5f1df2449
--- /dev/null
+++ b/tests/e2e/extentions/index.js
@@ -0,0 +1,3 @@
+import { toBeJSON } from "./toBeJSON.js";
+
+export default { toBeJSON };
diff --git a/tests/e2e/extentions/toBeJSON.js b/tests/e2e/extentions/toBeJSON.js
new file mode 100644
index 000000000..e71bee93d
--- /dev/null
+++ b/tests/e2e/extentions/toBeJSON.js
@@ -0,0 +1,15 @@
+export function toBeJSON(received) {
+ const pass = JSON.parse(received);
+
+ if (pass) {
+ return {
+ message: () => "passed",
+ pass: true
+ };
+ } else {
+ return {
+ message: () => "failed",
+ pass: false
+ };
+ }
+}
diff --git a/tests/e2e/main.spec.js b/tests/e2e/main.spec.js
new file mode 100644
index 000000000..b541550af
--- /dev/null
+++ b/tests/e2e/main.spec.js
@@ -0,0 +1,94 @@
+// import {
+// clickMenuItemById,
+// ipcMainCallFirstListener,
+// ipcRendererCallFirstListener,
+// ipcMainInvokeHandler,
+// ipcRendererInvoke
+// } from "electron-playwright-helpers";
+
+// import jimp from "jimp";
+
+// test("trigger IPC listener via main process", async () => {
+// electronApp.evaluate(({ ipcMain }) => {
+// ipcMain.emit("new-window");
+// });
+// const newPage = await electronApp.waitForEvent("window");
+// expect(newPage).toBeTruthy();
+// expect(await newPage.title()).toBe("Window 3");
+// page = newPage;
+// });
+
+// test("send IPC message from renderer", async () => {
+// // evaluate this script in render process
+// // requires webPreferences.nodeIntegration true and contextIsolation false
+// await page.evaluate(() => {
+// // eslint-disable-next-line @typescript-eslint/no-var-requires
+// require("electron").ipcRenderer.send("new-window");
+// });
+// const newPage = await electronApp.waitForEvent("window");
+// expect(newPage).toBeTruthy();
+// expect(await newPage.title()).toBe("Window 4");
+// page = newPage;
+// });
+
+// test("receive IPC invoke/handle via renderer", async () => {
+// // evaluate this script in RENDERER process and collect the result
+// const result = await ipcRendererInvoke(page, "how-many-windows");
+// expect(result).toBe(4);
+// });
+
+// test("receive IPC handle data via main", async () => {
+// // evaluate this script in MAIN process and collect the result
+// const result = await ipcMainInvokeHandler(electronApp, "how-many-windows");
+// expect(result).toBe(4);
+// });
+
+// test("receive synchronous data via ipcRendererCallFirstListener()", async () => {
+// const data = await ipcRendererCallFirstListener(page, "get-synchronous-data");
+// expect(data).toBe("Synchronous Data");
+// });
+
+// test("receive asynchronous data via ipcRendererCallFirstListener()", async () => {
+// const data = await ipcRendererCallFirstListener(
+// page,
+// "get-asynchronous-data"
+// );
+// expect(data).toBe("Asynchronous Data");
+// });
+
+// test("receive synchronous data via ipcMainCallFirstListener()", async () => {
+// const data = await ipcMainCallFirstListener(
+// electronApp,
+// "main-synchronous-data"
+// );
+// expect(data).toBe("Main Synchronous Data");
+// });
+
+// test("receive asynchronous data via ipcMainCallFirstListener()", async () => {
+// const data = await ipcMainCallFirstListener(
+// electronApp,
+// "main-asynchronous-data"
+// );
+// expect(data).toBe("Main Asynchronous Data");
+// });
+
+// test("select a menu item via the main process", async () => {
+// await clickMenuItemById(electronApp, "new-window");
+// const newPage = await electronApp.waitForEvent("window");
+// expect(newPage).toBeTruthy();
+// expect(await newPage.title()).toBe("Window 5");
+// page = newPage;
+// });
+
+// test("make sure two screenshots of the same page match", async ({ page }) => {
+// // take a screenshot of the current page
+// const screenshot1 = await page.screenshot();
+// // create a visual hash using Jimp
+// const screenshot1hash = (await jimp.read(screenshot1)).hash();
+// // take a screenshot of the page
+// const screenshot2 = await page.screenshot();
+// // create a visual hash using Jimp
+// const screenshot2hash = (await jimp.read(screenshot2)).hash();
+// // compare the two hashes
+// expect(screenshot1hash).toEqual(screenshot2hash);
+// });
diff --git a/tests/e2e/pageObjectModel/gallery.js b/tests/e2e/pageObjectModel/gallery.js
new file mode 100644
index 000000000..a6a87ddba
--- /dev/null
+++ b/tests/e2e/pageObjectModel/gallery.js
@@ -0,0 +1,56 @@
+import { expect } from "@playwright/test";
+import { modVApp } from ".";
+
+export const gallery = {
+ async addModuleToFocusedGroup(moduleName) {
+ const { page } = modVApp;
+
+ await page
+ .locator(".gallery-item ", {
+ has: page.locator(`text="${moduleName}"`)
+ })
+ .dblclick({
+ // position derived from Playwright recording - might not be accurate on
+ // different gallery item sizes
+ position: {
+ x: 98,
+ y: 27
+ }
+ });
+ },
+
+ async addModuleToGroupByName(moduleName, groupId) {
+ const { page, groups } = modVApp;
+
+ if (!groupId) {
+ const userGroups = await groups.getUserGroups();
+ groupId = userGroups[0].id;
+ }
+
+ const galleryItem = await page.locator(".gallery-item ", {
+ has: page.locator(`text="${moduleName}"`)
+ });
+
+ await galleryItem.scrollIntoViewIfNeeded();
+
+ const { modules: groupModules } = groups.getLocators(groupId);
+
+ const numberOfActiveModulesInGroup = await groupModules
+ .locator(".active-module")
+ .count();
+
+ await galleryItem.hover();
+ await page.mouse.down();
+ await groupModules.hover();
+ await groupModules.hover();
+ await page.mouse.up();
+
+ // Using expect.toHaveCount to wait for UI and subsequent state update
+ await expect(groupModules).toHaveCount(numberOfActiveModulesInGroup + 1);
+
+ const state = await modVApp.evaluateWorkerState();
+ const group = state.groups.groups.find(group => group.id === groupId);
+
+ return group.modules.pop();
+ }
+};
diff --git a/tests/e2e/pageObjectModel/groups.js b/tests/e2e/pageObjectModel/groups.js
new file mode 100644
index 000000000..a9346a672
--- /dev/null
+++ b/tests/e2e/pageObjectModel/groups.js
@@ -0,0 +1,81 @@
+import { modVApp } from ".";
+import constants from "../../../src/application/constants";
+
+const id = (strings, groupId) => [`#group-${groupId}`, ...strings].join("");
+
+export const groups = {
+ get newGroupButton() {
+ return modVApp.page.locator("#new-group-button");
+ },
+
+ get elements() {
+ return modVApp.page.locator(".groups .group");
+ },
+
+ getLocators(groupId) {
+ const { page } = modVApp;
+
+ const nameLocator = page.locator(id`${groupId} .group__name`);
+
+ return {
+ controlsButton: page.locator(id`${groupId} .group__controlsButton`),
+ enabledCheckbox: page.locator(id`${groupId} .group__enabledCheckbox`),
+ inheritSelect: page.locator(id`${groupId} .group__inheritSelect select`),
+ clearingCheckbox: page.locator(id`${groupId} .group__clearingCheckbox`),
+ pipelineCheckbox: page.locator(id`${groupId} .group__pipelineCheckbox`),
+ alphaRange: page.locator(
+ id`${groupId} .group__alphaRange input[type=range]`
+ ),
+ blendModeSelect: page.locator(
+ id`${groupId} .group__blendModeSelect select`
+ ),
+ name: nameLocator,
+ nameDisplay: nameLocator.locator("span"),
+ nameInput: nameLocator.locator("input[type=text]"),
+ modules: page.locator(id`${groupId} .group__modules`)
+ };
+ },
+
+ async showControls(groupId) {
+ const { controlsButton } = this.getLocators(groupId);
+
+ const controlsHidden = await controlsButton.evaluate(el =>
+ el.classList.contains("group__controlsButton-hidden")
+ );
+
+ if (controlsHidden) {
+ await controlsButton.click();
+ }
+ },
+
+ async getUserGroups(groups) {
+ if (!groups) {
+ ({ groups } = await modVApp.groups.mainState());
+ }
+
+ return groups.filter(group => group.name !== constants.GALLERY_GROUP_NAME);
+ },
+
+ async getFirstUserGroupIdAndIndex() {
+ const { groups } = await this.mainState();
+ const userGroups = await this.getUserGroups();
+ const groupId = userGroups[0].id;
+ const groupIndex = groups.findIndex(group => group.id === groupId);
+
+ return { groupId, groupIndex };
+ },
+
+ async focusGroup(groupId) {
+ await this.getLocators(groupId).name.click();
+ },
+
+ async mainState() {
+ const groups = await modVApp.evaluateMainState(`groups`);
+ return groups;
+ },
+
+ async workerState() {
+ const groups = await modVApp.evaluateWorkerState(`groups`);
+ return groups;
+ }
+};
diff --git a/tests/e2e/pageObjectModel/index.js b/tests/e2e/pageObjectModel/index.js
new file mode 100644
index 000000000..5c165e2f1
--- /dev/null
+++ b/tests/e2e/pageObjectModel/index.js
@@ -0,0 +1,145 @@
+import path from "path";
+import { expect, test } from "@playwright/test";
+import {
+ findLatestBuild,
+ ipcRendererInvoke,
+ parseElectronApp
+} from "electron-playwright-helpers";
+import { _electron as electron } from "playwright";
+import { groups } from "./groups";
+import { gallery } from "./gallery";
+import { modules } from "./modules";
+import { tabs } from "./tabs";
+
+class ModVApp {
+ groups = groups;
+ gallery = gallery;
+ modules = modules;
+ tabs = tabs;
+
+ /**
+ * @param {import('@playwright/test').Page} page
+ */
+ constructor() {
+ this.electronApp = null;
+ this.page = null;
+
+ test.beforeAll(async () => {
+ // find the latest build in the out directory
+ const latestBuild = findLatestBuild(path.resolve("./dist_electron"));
+ // parse the directory and find paths and other info
+ const appInfo = parseElectronApp(latestBuild);
+ // set the CI environment variable to true
+ process.env.CI = "e2e";
+
+ this.electronApp = await electron.launch({
+ args: [
+ appInfo.main
+ // "--use-fake-device-for-media-stream",
+ // "--use-fake-ui-for-media-stream"
+ ],
+ executablePath: appInfo.executable
+ });
+
+ this.electronApp.on("window", async page => {
+ // capture errors
+ page.on("pageerror", error => {
+ console.error(error);
+ });
+
+ // capture console messages
+ page.on("console", msg => {
+ console.log(msg.text());
+ });
+ });
+
+ // console.info(" ℹ Waiting for modV to become ready…");
+ this.page = await modVApp.electronApp.firstWindow();
+ await this.waitUntilModVReady();
+ });
+ }
+
+ async waitUntilModVReady(resolver) {
+ if (!resolver) {
+ const promise = new Promise(async resolve => {
+ resolver = resolve;
+
+ this.waitUntilModVReady(resolver);
+ });
+
+ return promise;
+ }
+
+ const isReady = await ipcRendererInvoke(this.page, "is-modv-ready");
+
+ if (!isReady) {
+ setTimeout(() => {
+ this.waitUntilModVReady(resolver);
+ }, 500);
+
+ return;
+ }
+
+ resolver();
+ }
+
+ async evaluateMainState(propertyPath) {
+ const { page } = this;
+
+ if (propertyPath) {
+ return page.evaluate(
+ propertyPath => window._get(window.modV.store.state, propertyPath),
+ propertyPath
+ );
+ }
+
+ return page.evaluate(() => window.modV.store.state);
+ }
+
+ async evaluateUIState() {
+ const { page } = this;
+
+ return page.evaluate(() => window.Vue.$store.state);
+ }
+
+ async evaluateWorkerState(propertyPath) {
+ const { page } = this;
+ const worker = page.workers()[0];
+
+ if (propertyPath) {
+ return worker.evaluate(
+ propertyPath => self._get(self.store.state, propertyPath),
+ propertyPath
+ );
+ }
+
+ return worker.evaluate(() => self.store.state);
+ }
+
+ async checkWorkerAndMainState(checks = [], propertyPath) {
+ for (let j = 0; j < 2; j += 1) {
+ for (let i = 0; i < checks.length; i += 1) {
+ const check = checks[i];
+
+ await check[1](
+ expect.poll(async () => {
+ if (j === 0) {
+ return check[0](await this.evaluateWorkerState(propertyPath));
+ } else {
+ return check[0](await this.evaluateMainState(propertyPath));
+ }
+ })
+ );
+ }
+ }
+ }
+
+ async generatePreset() {
+ const { page } = this;
+
+ return page.evaluate(async () => await window.modV.generatePreset());
+ }
+}
+
+const modVApp = new ModVApp();
+export { modVApp };
diff --git a/tests/e2e/pageObjectModel/modules.js b/tests/e2e/pageObjectModel/modules.js
new file mode 100644
index 000000000..e6b6a27cc
--- /dev/null
+++ b/tests/e2e/pageObjectModel/modules.js
@@ -0,0 +1,54 @@
+import { modVApp } from ".";
+
+const id = (strings, moduleId) =>
+ [`#active-module-${moduleId}`, ...strings].join("");
+
+export const modules = {
+ async mainState() {
+ const mainState = await modVApp.evaluateMainState();
+ return mainState.modules;
+ },
+
+ async workerState() {
+ const workerState = await modVApp.evaluateWorkerState();
+ return workerState.modules;
+ },
+
+ getLocators(moduleId) {
+ const { page } = modVApp;
+
+ return {
+ activeModule: page.locator(id`${moduleId}`),
+ name: page.locator(id`${moduleId} .active-module__name`),
+ alphaRange: page.locator(id`${moduleId} .active-module__alphaRange`),
+ enabledCheckbox: page.locator(
+ id`${moduleId} .active-module__enabledCheckbox`
+ ),
+ blendModeSelect: page.locator(
+ id`${moduleId} .active-module__blendModeSelect`
+ )
+ };
+ },
+
+ async getInputIds(moduleId) {
+ const modulesState = await this.workerState();
+
+ const { $props, meta } = modulesState.active[moduleId];
+
+ const { alphaInputId, enabledInputId, compositeOperationInputId } = meta;
+
+ const inputIds = {
+ meta: {
+ alphaInputId,
+ enabledInputId,
+ compositeOperationInputId
+ }
+ };
+
+ inputIds.props = Object.fromEntries(
+ Object.entries($props).map(([propName, value]) => [propName, value.id])
+ );
+
+ return inputIds;
+ }
+};
diff --git a/tests/e2e/pageObjectModel/tabs.js b/tests/e2e/pageObjectModel/tabs.js
new file mode 100644
index 000000000..ef3557b82
--- /dev/null
+++ b/tests/e2e/pageObjectModel/tabs.js
@@ -0,0 +1,12 @@
+import { modVApp } from ".";
+
+const tab = strings => `.lm_tab[title="${[...strings].join("")}"]`;
+
+export const tabs = {
+ getLocators() {
+ const { page } = modVApp;
+ return {
+ inputConfig: page.locator(tab`Input Config`)
+ };
+ }
+};
diff --git a/tests/e2e/spec/general/defaultGroupIsFocusedByDefault.spec.js b/tests/e2e/spec/general/defaultGroupIsFocusedByDefault.spec.js
new file mode 100644
index 000000000..3b85f0c7c
--- /dev/null
+++ b/tests/e2e/spec/general/defaultGroupIsFocusedByDefault.spec.js
@@ -0,0 +1,19 @@
+import { expect, test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+
+test("default group is focused by default", async () => {
+ const { page } = modVApp;
+
+ const firstGroupEl = page.locator(".group").first();
+ const firstGroupElId = (await firstGroupEl.getAttribute("id")).replace(
+ "group-",
+ ""
+ );
+ const focusIndicator = firstGroupEl.locator(".group__focusIndicator");
+
+ await expect(focusIndicator).toHaveCount(1);
+
+ const { focus } = await modVApp.evaluateUIState();
+
+ expect(focus.id).toBe(firstGroupElId);
+});
diff --git a/tests/e2e/spec/general/renderersAreRegistered.spec.js b/tests/e2e/spec/general/renderersAreRegistered.spec.js
new file mode 100644
index 000000000..b46d80f40
--- /dev/null
+++ b/tests/e2e/spec/general/renderersAreRegistered.spec.js
@@ -0,0 +1,10 @@
+import { expect, test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+
+test("renderers are registered", async () => {
+ const expectedRenderers = ["2d", "isf", "shader", "three"];
+
+ const { renderers } = await modVApp.evaluateMainState();
+
+ expect(expectedRenderers.sort()).toEqual(Object.keys(renderers).sort());
+});
diff --git a/tests/e2e/spec/general/rendersTheMainWindow.spec.js b/tests/e2e/spec/general/rendersTheMainWindow.spec.js
new file mode 100644
index 000000000..86548526d
--- /dev/null
+++ b/tests/e2e/spec/general/rendersTheMainWindow.spec.js
@@ -0,0 +1,9 @@
+import { expect, test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+
+test("renders the main window", async () => {
+ const { page } = modVApp;
+
+ const title = await page.title();
+ expect(title).toBe("modV");
+});
diff --git a/tests/e2e/spec/general/searchGalleryAndAddModuleToGroup.spec.js b/tests/e2e/spec/general/searchGalleryAndAddModuleToGroup.spec.js
new file mode 100644
index 000000000..1d1cf27b7
--- /dev/null
+++ b/tests/e2e/spec/general/searchGalleryAndAddModuleToGroup.spec.js
@@ -0,0 +1,34 @@
+import { expect, test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+
+test("search gallery and add module to group", async () => {
+ const { page } = modVApp;
+
+ const searchInput = await page.locator("input.gallery-search");
+ await searchInput.click();
+
+ await searchInput.fill("ball");
+
+ // Double click c:nth-child(2) > .smooth-dnd-container > div > .gallery-item > canvas >> nth=0
+ await page
+ .locator("c:nth-child(2) > .smooth-dnd-container > div > .gallery-item ", {
+ hasText: "Ball"
+ })
+ .first()
+ .dblclick({
+ position: {
+ x: 98,
+ y: 27
+ }
+ });
+
+ await page.waitForSelector(".group__modules .active-module");
+ const activeModules = await page.locator(".group__modules .active-module");
+ expect(await activeModules.count()).toBe(1);
+
+ const activeModule = await activeModules
+ .first()
+ .locator(".active-module__name");
+ const activeModuleTitle = await activeModule.textContent();
+ expect(activeModuleTitle.trim()).toBe("Ball");
+});
diff --git a/tests/e2e/spec/groups/alphaStateCanBeSet.spec.js b/tests/e2e/spec/groups/alphaStateCanBeSet.spec.js
new file mode 100644
index 000000000..4626a56a8
--- /dev/null
+++ b/tests/e2e/spec/groups/alphaStateCanBeSet.spec.js
@@ -0,0 +1,26 @@
+import { test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+import { setRangeValue } from "../../utils/setRangeValue";
+
+test("alpha state can be set", async () => {
+ test.setTimeout(60 * 1000);
+
+ const {
+ groupIndex,
+ groupId
+ } = await modVApp.groups.getFirstUserGroupIdAndIndex();
+
+ const { alphaRange } = modVApp.groups.getLocators(groupId);
+
+ await modVApp.groups.showControls(groupId);
+
+ for (let i = 0; i < 5; i += 1) {
+ const value = Math.random();
+ await setRangeValue(alphaRange, value);
+
+ await modVApp.checkWorkerAndMainState(
+ [[state => state, e => e.toBeCloseTo(value)]],
+ `groups.groups[${groupIndex}].alpha`
+ );
+ }
+});
diff --git a/tests/e2e/spec/groups/backspaceRemovesFocusedGroup.spec.js b/tests/e2e/spec/groups/backspaceRemovesFocusedGroup.spec.js
new file mode 100644
index 000000000..f55270dbd
--- /dev/null
+++ b/tests/e2e/spec/groups/backspaceRemovesFocusedGroup.spec.js
@@ -0,0 +1,41 @@
+import { expect, test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+
+test("backspace removes focused group", async () => {
+ const { page } = modVApp;
+
+ const {
+ groups: { length: groupsLength }
+ } = await modVApp.groups.mainState();
+
+ const userGroups = await modVApp.groups.getUserGroups();
+ let groupId = userGroups[groupsLength - 2].id;
+
+ await modVApp.groups.focusGroup(groupId);
+ await page.keyboard.press("Backspace");
+
+ await expect(modVApp.groups.elements).toHaveCount(groupsLength - 2);
+
+ // await modVApp.checkWorkerAndMainState(state => {
+ // expect(state.length).toBe(groupsLength - 1);
+ // expect(state.findIndex(group => group.id === groupId)).toBe(-1);
+ // }, `groups.groups`);
+
+ await modVApp.checkWorkerAndMainState(
+ [
+ [state => state.length, e => e.toBe(groupsLength - 1)],
+ [state => state.findIndex(group => group.id === groupId), e => e.toBe(-1)]
+ ],
+ `groups.groups`
+ );
+
+ // Add a group back just in case this test shares the same worker as another
+ await modVApp.groups.newGroupButton.click();
+ await expect
+ .poll(async () => page.locator(".group").count())
+ .toBeGreaterThanOrEqual(1);
+
+ ({ groupId } = await modVApp.groups.getFirstUserGroupIdAndIndex());
+ const { enabledCheckbox } = modVApp.groups.getLocators(groupId);
+ await enabledCheckbox.click();
+});
diff --git a/tests/e2e/spec/groups/blendModeStateCanBeSet.spec.js b/tests/e2e/spec/groups/blendModeStateCanBeSet.spec.js
new file mode 100644
index 000000000..9dc0154eb
--- /dev/null
+++ b/tests/e2e/spec/groups/blendModeStateCanBeSet.spec.js
@@ -0,0 +1,29 @@
+import { test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+import compositeOperations from "../../../../src/util/composite-operations";
+
+test("blend mode state can be set", async () => {
+ const {
+ groupIndex,
+ groupId
+ } = await modVApp.groups.getFirstUserGroupIdAndIndex();
+
+ const { blendModeSelect } = modVApp.groups.getLocators(groupId);
+
+ await modVApp.groups.showControls(groupId);
+
+ const values = [
+ ...compositeOperations[0].children.map(({ value }) => value),
+ ...compositeOperations[1].children.map(({ value }) => value)
+ ];
+
+ for (let i = 0; i < values.length; i += 1) {
+ const value = values[i];
+ await blendModeSelect.selectOption(String(value));
+
+ await modVApp.checkWorkerAndMainState(
+ [[state => state, e => e.toBe(value)]],
+ `groups.groups[${groupIndex}].compositeOperation`
+ );
+ }
+});
diff --git a/tests/e2e/spec/groups/clearingStateCanBeToggledBetween0and1.spec.js b/tests/e2e/spec/groups/clearingStateCanBeToggledBetween0and1.spec.js
new file mode 100644
index 000000000..8e1f84e29
--- /dev/null
+++ b/tests/e2e/spec/groups/clearingStateCanBeToggledBetween0and1.spec.js
@@ -0,0 +1,22 @@
+import { test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+
+test("clearing state can be toggled beween 0 and 1", async () => {
+ const {
+ groupIndex,
+ groupId
+ } = await modVApp.groups.getFirstUserGroupIdAndIndex();
+
+ const { clearingCheckbox } = modVApp.groups.getLocators(groupId);
+
+ await modVApp.groups.showControls(groupId);
+
+ for (let i = 0; i < 2; i += 1) {
+ await clearingCheckbox.click();
+
+ await modVApp.checkWorkerAndMainState(
+ [[state => state[groupIndex].clearing, e => e.toBe(Number(!i))]],
+ `groups.groups`
+ );
+ }
+});
diff --git a/tests/e2e/spec/groups/createsADefaultGroup.spec.js b/tests/e2e/spec/groups/createsADefaultGroup.spec.js
new file mode 100644
index 000000000..c2cd27df4
--- /dev/null
+++ b/tests/e2e/spec/groups/createsADefaultGroup.spec.js
@@ -0,0 +1,11 @@
+import { expect, test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+
+test("creates a default group", async () => {
+ await expect(modVApp.groups.elements).toHaveCount(1);
+
+ await modVApp.checkWorkerAndMainState(
+ [[state => state.length, e => e.toBe(2)]],
+ `groups.groups`
+ );
+});
diff --git a/tests/e2e/spec/groups/enabledStateCanBeToggledBetween01and2.spec.js b/tests/e2e/spec/groups/enabledStateCanBeToggledBetween01and2.spec.js
new file mode 100644
index 000000000..1efbc3091
--- /dev/null
+++ b/tests/e2e/spec/groups/enabledStateCanBeToggledBetween01and2.spec.js
@@ -0,0 +1,48 @@
+import { test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+
+test("enabled state can be toggled between 0, 1 and 2", async () => {
+ const {
+ groupIndex,
+ groupId
+ } = await modVApp.groups.getFirstUserGroupIdAndIndex();
+
+ const { enabledCheckbox } = modVApp.groups.getLocators(groupId);
+
+ await enabledCheckbox.click();
+
+ await modVApp.checkWorkerAndMainState(
+ [[state => state[groupIndex].enabled, e => e.toBe(0)]],
+ `groups.groups`
+ );
+
+ await enabledCheckbox.click();
+
+ await modVApp.checkWorkerAndMainState(
+ [[state => state, e => e.toBe(1)]],
+ `groups.groups[${groupIndex}].enabled`
+ );
+
+ await modVApp.page.keyboard.down("Alt");
+ await enabledCheckbox.click();
+ await modVApp.page.keyboard.up("Alt");
+
+ await modVApp.checkWorkerAndMainState(
+ [[state => state[groupIndex].enabled, e => e.toBe(2)]],
+ `groups.groups`
+ );
+
+ await enabledCheckbox.click();
+
+ await modVApp.checkWorkerAndMainState(
+ [[state => state[groupIndex].enabled, e => e.toBe(0)]],
+ `groups.groups`
+ );
+
+ await enabledCheckbox.click();
+
+ await modVApp.checkWorkerAndMainState(
+ [[state => state[groupIndex].enabled, e => e.toBe(1)]],
+ `groups.groups`
+ );
+});
diff --git a/tests/e2e/spec/groups/groupNameCanBeChanged.spec.js b/tests/e2e/spec/groups/groupNameCanBeChanged.spec.js
new file mode 100644
index 000000000..fb14ad9c3
--- /dev/null
+++ b/tests/e2e/spec/groups/groupNameCanBeChanged.spec.js
@@ -0,0 +1,26 @@
+import { expect, test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+
+test("group name can be changed", async () => {
+ const {
+ groupIndex,
+ groupId
+ } = await modVApp.groups.getFirstUserGroupIdAndIndex();
+
+ const { nameDisplay, nameInput } = modVApp.groups.getLocators(groupId);
+
+ await nameDisplay.dblclick();
+ await expect(nameInput).toBeFocused();
+
+ const newGroupName = "Post FX";
+
+ await nameInput.type(newGroupName);
+ await nameInput.press("Enter");
+
+ await expect(nameDisplay).toHaveText(newGroupName);
+
+ await modVApp.checkWorkerAndMainState(
+ [[state => state, e => e.toBe(newGroupName)]],
+ `groups.groups[${groupIndex}].name`
+ );
+});
diff --git a/tests/e2e/spec/groups/groupsCanBeRearranged.spec.js b/tests/e2e/spec/groups/groupsCanBeRearranged.spec.js
new file mode 100644
index 000000000..adb7c7329
--- /dev/null
+++ b/tests/e2e/spec/groups/groupsCanBeRearranged.spec.js
@@ -0,0 +1,39 @@
+import { expect, test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+
+test("groups can be rearranged", async () => {
+ const {
+ groups: { elements, getLocators, getUserGroups, newGroupButton },
+ page
+ } = modVApp;
+
+ let groups = await getUserGroups();
+
+ if (groups.length < 2) {
+ await newGroupButton.click();
+ await expect(elements).toHaveCount(2);
+ }
+
+ groups = await getUserGroups();
+
+ // https://playwright.dev/docs/input#dragging-manually
+ await getLocators(groups[0].id).name.hover();
+ await page.mouse.down();
+ await page.mouse.move(200, 200);
+ await page.mouse.move(200, 200);
+ await page.mouse.up();
+
+ await modVApp.checkWorkerAndMainState(
+ [
+ [
+ async state => (await getUserGroups(state))[0].id,
+ e => e.toBe(groups[1].id)
+ ],
+ [
+ async state => (await getUserGroups(state))[1].id,
+ e => e.toBe(groups[0].id)
+ ]
+ ],
+ `groups.groups`
+ );
+});
diff --git a/tests/e2e/spec/groups/inheritAndInheritFromStateCanBeSet.spec.js b/tests/e2e/spec/groups/inheritAndInheritFromStateCanBeSet.spec.js
new file mode 100644
index 000000000..5a2450ea6
--- /dev/null
+++ b/tests/e2e/spec/groups/inheritAndInheritFromStateCanBeSet.spec.js
@@ -0,0 +1,41 @@
+import { test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+
+test("inherit and inheritFrom state can be set", async () => {
+ const userGroups = modVApp.groups.getUserGroups();
+ const {
+ groupIndex,
+ groupId
+ } = await modVApp.groups.getFirstUserGroupIdAndIndex();
+
+ const { inheritSelect } = modVApp.groups.getLocators(groupId);
+
+ await modVApp.groups.showControls(groupId);
+
+ const values = [-2, -1];
+ for (let i = 0; i < userGroups.length; i += 1) {
+ values.push(i);
+ }
+
+ for (let i = 0; i < values.length; i += 1) {
+ const value = values[i];
+ await inheritSelect.selectOption(String(value));
+
+ if (value === -2) {
+ await modVApp.checkWorkerAndMainState(
+ [[state => state[groupIndex].inherit, e => e.toBe(false)]],
+ `groups.groups`
+ );
+ } else {
+ await modVApp.checkWorkerAndMainState(
+ [[state => state[groupIndex].inherit, e => e.toBe(true)]],
+ `groups.groups`
+ );
+
+ await modVApp.checkWorkerAndMainState(
+ [[state => state[groupIndex].inheritFrom, e => e.toBe(value)]],
+ `groups.groups`
+ );
+ }
+ }
+});
diff --git a/tests/e2e/spec/groups/newGroupButtonCreatesANewGroup.spec.js b/tests/e2e/spec/groups/newGroupButtonCreatesANewGroup.spec.js
new file mode 100644
index 000000000..b9016bfd8
--- /dev/null
+++ b/tests/e2e/spec/groups/newGroupButtonCreatesANewGroup.spec.js
@@ -0,0 +1,15 @@
+import { expect, test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+
+test("new group button creates a new group", async () => {
+ const { length: groupsLength } = (await modVApp.groups.mainState()).groups;
+
+ await modVApp.groups.newGroupButton.click();
+
+ await expect(modVApp.groups.elements).toHaveCount(groupsLength);
+
+ await modVApp.checkWorkerAndMainState(
+ [[state => state.length, e => e.toBe(groupsLength + 1)]],
+ `groups.groups`
+ );
+});
diff --git a/tests/e2e/spec/groups/pipelineStateCanBeToggledBetween0and1.spec.js b/tests/e2e/spec/groups/pipelineStateCanBeToggledBetween0and1.spec.js
new file mode 100644
index 000000000..e5c5c1107
--- /dev/null
+++ b/tests/e2e/spec/groups/pipelineStateCanBeToggledBetween0and1.spec.js
@@ -0,0 +1,22 @@
+import { test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+
+test("pipeline state can be toggled beween 0 and 1", async () => {
+ const {
+ groupIndex,
+ groupId
+ } = await modVApp.groups.getFirstUserGroupIdAndIndex();
+
+ const { pipelineCheckbox } = modVApp.groups.getLocators(groupId);
+
+ await modVApp.groups.showControls(groupId);
+
+ for (let i = 0; i < 2; i += 1) {
+ await pipelineCheckbox.click();
+
+ await modVApp.checkWorkerAndMainState(
+ [[state => state[groupIndex].pipeline, e => e.toBe(i === 0 ? 1 : 0)]],
+ `groups.groups`
+ );
+ }
+});
diff --git a/tests/e2e/spec/inputLinks/inputConfigUpdatesWhenControlIsFocused.spec.js b/tests/e2e/spec/inputLinks/inputConfigUpdatesWhenControlIsFocused.spec.js
new file mode 100644
index 000000000..dbe3173b8
--- /dev/null
+++ b/tests/e2e/spec/inputLinks/inputConfigUpdatesWhenControlIsFocused.spec.js
@@ -0,0 +1,29 @@
+import { expect, test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+
+test("input config updates when control is focused", async () => {
+ const moduleId = await modVApp.gallery.addModuleToGroupByName("Ball");
+
+ const { name } = modVApp.modules.getLocators(moduleId);
+ await name.waitFor("visible");
+ await name.click();
+
+ const inspector = modVApp.page.locator(`#module-inspector-${moduleId}`);
+ await expect(inspector.locator(".module-inspector__title")).toHaveText(
+ "Ball"
+ );
+
+ const { inputConfig } = modVApp.tabs.getLocators();
+ await inputConfig.click();
+
+ const inputIds = await modVApp.modules.getInputIds(moduleId);
+
+ const amountControl = modVApp.page.locator(
+ `#module-control-${inputIds.props.amount}`
+ );
+ await amountControl.click();
+
+ await expect(modVApp.page.locator(".input-config__title")).toHaveText(
+ "Ball: Amount"
+ );
+});
diff --git a/tests/e2e/spec/presets/generatesAPresetWithExpectedKeys.spec.js b/tests/e2e/spec/presets/generatesAPresetWithExpectedKeys.spec.js
new file mode 100644
index 000000000..63ece0e32
--- /dev/null
+++ b/tests/e2e/spec/presets/generatesAPresetWithExpectedKeys.spec.js
@@ -0,0 +1,21 @@
+import { expect, test } from "@playwright/test";
+import { modVApp } from "../../pageObjectModel";
+
+test("generates a preset with expected keys", async () => {
+ const preset = await modVApp.generatePreset();
+
+ const expectedPresetKeys = [
+ "expressions",
+ "groups",
+ "inputs",
+ "midi",
+ "modules",
+ "tweens"
+ ];
+
+ expect(preset).toBeDefined();
+ expect(preset).toBeJSON();
+ expect(expectedPresetKeys.sort()).toEqual(
+ Object.keys(JSON.parse(preset)).sort()
+ );
+});
diff --git a/tests/e2e/utils/setRangeValue.js b/tests/e2e/utils/setRangeValue.js
new file mode 100644
index 000000000..3c684473b
--- /dev/null
+++ b/tests/e2e/utils/setRangeValue.js
@@ -0,0 +1,7 @@
+export function setRangeValue(locator, value) {
+ return locator.evaluate((e, value) => {
+ e.value = value;
+ e.dispatchEvent(new Event("input", { bubbles: true }));
+ e.dispatchEvent(new Event("change", { bubbles: true }));
+ }, value);
+}
diff --git a/vue.config.js b/vue.config.js
index 70eb29621..c8333b9ff 100644
--- a/vue.config.js
+++ b/vue.config.js
@@ -126,6 +126,16 @@ module.exports = {
config
.plugin("define")
.use(DefinePlugin, [{ "process.env.FLUENTFFMPEG_COV": false }]);
+
+ config.module
+ .rule("babel")
+ .test(/\.m?js$/)
+ .exclude.add(/node_modules/)
+ .end()
+ .use("babelloader")
+ .loader("babel-loader", {
+ presets: [["@babel/preset-env", { targets: "defaults" }]]
+ });
},
chainWebpackRendererProcess: config => {
diff --git a/yarn.lock b/yarn.lock
index 3bb1fdaa2..605cdb563 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1322,6 +1322,18 @@
ajv "^6.12.0"
ajv-keywords "^3.4.1"
+"@electron/asar@^3.2.2":
+ version "3.2.3"
+ resolved "https://registry.yarnpkg.com/@electron/asar/-/asar-3.2.3.tgz#f598db50061ae5f90ad651f0255366b4e818000e"
+ integrity sha512-wmOfE6szYyqZhRIiLH+eyZEp+bGcJI0OD/SCvSUrfBE0jvauyGYO2ZhpWxmNCcDojKu5DYrsVqT5BOCZZ01XIg==
+ dependencies:
+ chromium-pickle-js "^0.2.0"
+ commander "^5.0.0"
+ glob "^7.1.6"
+ minimatch "^3.0.4"
+ optionalDependencies:
+ "@types/glob" "^7.1.1"
+
"@electron/get@^2.0.0":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@electron/get/-/get-2.0.2.tgz#ae2a967b22075e9c25aaf00d5941cd79c21efd7e"
@@ -1483,6 +1495,16 @@
mkdirp "^1.0.4"
rimraf "^3.0.2"
+"@playwright/test@^1.31.2":
+ version "1.31.2"
+ resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.31.2.tgz#426d8545143a97a6fed250a2a27aa1c8e5e2548e"
+ integrity sha512-BYVutxDI4JeZKV1+ups6dt5WiqKhjBtIYowyZIJ3kBDmJgsuPKsqqKNIMFbUePLSCmp2cZu+BDL427RcNKTRYw==
+ dependencies:
+ "@types/node" "*"
+ playwright-core "1.31.2"
+ optionalDependencies:
+ fsevents "2.3.2"
+
"@samverschueren/stream-to-observable@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f"
@@ -2006,6 +2028,35 @@
resolved "https://registry.yarnpkg.com/@vue/preload-webpack-plugin/-/preload-webpack-plugin-1.1.1.tgz#18723530d304f443021da2292d6ec9502826104a"
integrity sha512-8VCoJeeH8tCkzhkpfOkt+abALQkS11OIHhte5MBzYaKMTqK0A3ZAKEUVAffsOklhEv7t0yrQt696Opnu9oAx+w==
+"@vue/reactivity@3.2.47":
+ version "3.2.47"
+ resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.47.tgz#1d6399074eadfc3ed35c727e2fd707d6881140b6"
+ integrity sha512-7khqQ/75oyyg+N/e+iwV6lpy1f5wq759NdlS1fpAhFXa8VeAIKGgk2E/C4VF59lx5b+Ezs5fpp/5WsRYXQiKxQ==
+ dependencies:
+ "@vue/shared" "3.2.47"
+
+"@vue/runtime-core@3.2.47":
+ version "3.2.47"
+ resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.47.tgz#406ebade3d5551c00fc6409bbc1eeb10f32e121d"
+ integrity sha512-RZxbLQIRB/K0ev0K9FXhNbBzT32H9iRtYbaXb0ZIz2usLms/D55dJR2t6cIEUn6vyhS3ALNvNthI+Q95C+NOpA==
+ dependencies:
+ "@vue/reactivity" "3.2.47"
+ "@vue/shared" "3.2.47"
+
+"@vue/runtime-dom@^3.2.47":
+ version "3.2.47"
+ resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.47.tgz#93e760eeaeab84dedfb7c3eaf3ed58d776299382"
+ integrity sha512-ArXrFTjS6TsDei4qwNvgrdmHtD930KgSKGhS5M+j8QxXrDJYLqYw4RRcDy1bz1m1wMmb6j+zGLifdVHtkXA7gA==
+ dependencies:
+ "@vue/runtime-core" "3.2.47"
+ "@vue/shared" "3.2.47"
+ csstype "^2.6.8"
+
+"@vue/shared@3.2.47":
+ version "3.2.47"
+ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.47.tgz#e597ef75086c6e896ff5478a6bfc0a7aa4bbd14c"
+ integrity sha512-BHGyyGN3Q97EZx0taMQ+OLNuZcW3d37ZEVmEAyeoA9ERdGvm9Irc/0Fua8SNyOtV1w6BS4q25wbMzJujO9HIfQ==
+
"@vue/web-component-wrapper@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@vue/web-component-wrapper/-/web-component-wrapper-1.2.0.tgz#bb0e46f1585a7e289b4ee6067dcc5a6ae62f1dd1"
@@ -3522,6 +3573,7 @@ call-bind@^1.0.0:
function-bind "^1.1.1"
get-intrinsic "^1.0.2"
+
call-limit@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/call-limit/-/call-limit-1.1.1.tgz#ef15f2670db3f1992557e2d965abc459e6e358d4"
@@ -4623,10 +4675,15 @@ csso@^4.0.2:
dependencies:
css-tree "1.0.0-alpha.39"
+csstype@^2.6.8:
+ version "2.6.21"
+ resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.21.tgz#2efb85b7cc55c80017c66a5ad7cbd931fda3a90e"
+ integrity sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==
+
csstype@^3.1.0:
- version "3.1.1"
- resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9"
- integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
+ integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
current-script-polyfill@^1.0.0:
version "1.0.0"
@@ -5235,6 +5292,13 @@ electron-osx-sign@^0.6.0:
minimist "^1.2.0"
plist "^3.0.1"
+electron-playwright-helpers@^1.5.3:
+ version "1.5.3"
+ resolved "https://registry.yarnpkg.com/electron-playwright-helpers/-/electron-playwright-helpers-1.5.3.tgz#a481d8d254b1eda669c8b0341f174cbcab170dcf"
+ integrity sha512-wtGq2kxxZliEcD+OLfwjNhsoMeFpqCWBxkwGWjzfILm74hMk+fSBLp9/3t38fH8aNp0waYI7uzYE1xNe+g4u/A==
+ dependencies:
+ "@electron/asar" "^3.2.2"
+
electron-publish@22.9.1:
version "22.9.1"
resolved "https://registry.yarnpkg.com/electron-publish/-/electron-publish-22.9.1.tgz#7cc76ac4cc53efd29ee31c1e5facb9724329068e"
@@ -6275,11 +6339,6 @@ follow-redirects@^1.0.0:
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc"
integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==
-font-list@^1.3.1:
- version "1.3.1"
- resolved "https://registry.yarnpkg.com/font-list/-/font-list-1.3.1.tgz#b6527e94045601b92971572220ce691714452aed"
- integrity sha512-/TvQbGLIdyfH4SBZkDwWlX0JNDVH98Q29SkgMJlIdmc4ObKu+RnB/B493lskB2Z8XZ2FZ532QOem4/PDgtoXUQ==
-
for-in@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@@ -6444,6 +6503,11 @@ fs.realpath@^1.0.0:
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
+fsevents@2.3.2:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
+ integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
+
fsevents@^1.2.7:
version "1.2.13"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38"
@@ -10516,6 +10580,18 @@ plask-wrap@^1.0.1:
resolved "https://registry.yarnpkg.com/plask-wrap/-/plask-wrap-1.0.1.tgz#c5f5c1b5d874c1c7d9b717f772126f25abb31ddf"
integrity sha1-xfXBtdh0wcfZtxf3chJvJauzHd8=
+playwright-core@1.31.2, playwright-core@^1.31.2:
+ version "1.31.2"
+ resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.31.2.tgz#debf4b215d14cb619adb7e511c164d068075b2ed"
+ integrity sha512-a1dFgCNQw4vCsG7bnojZjDnPewZcw7tZUNFN0ZkcLYKj+mPmXvg4MpaaKZ5SgqPsOmqIf2YsVRkgqiRDxD+fDQ==
+
+playwright@^1.31.2:
+ version "1.31.2"
+ resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.31.2.tgz#4252280586c596746122cd1fdf9f8ff6a63fa852"
+ integrity sha512-jpC47n2PKQNtzB7clmBuWh6ftBRS/Bt5EGLigJ9k2QAKcNeYXZkEaDH5gmvb6+AbcE0DO6GnXdbl9ogG6Eh+og==
+ dependencies:
+ playwright-core "1.31.2"
+
please-upgrade-node@^3.0.2:
version "3.2.0"
resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942"