diff --git a/.travis.yml b/.travis.yml
index 969909f79..1a04bcbe1 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,14 +1,10 @@
 language: node_js
 dist: trusty
-addons:
-  chrome: stable
 node_js:
-- 8
+- 10
 - node
 env:
   - NODE_ENV=production
-before_install:
-  - google-chrome-stable --headless --no-sandbox --remote-debugging-port=9222 &
 install:
 - npm --production=false install
 - npm --production=false update
@@ -24,7 +20,7 @@ jobs:
       - npm run docs
       - npm run tap
     - stage: deploy
-      node_js: 8
+      node_js: 10
       script: npm run build
       before_deploy:
       - VPKG=$($(npm bin)/json -f package.json version)
diff --git a/package.json b/package.json
index 91c1c6de3..b69fef97b 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,6 @@
     "babel-loader": "^7.1.4",
     "babel-polyfill": "^6.22.0",
     "babel-preset-env": "^1.6.1",
-    "chromeless": "^1.5.1",
     "copy-webpack-plugin": "^4.5.1",
     "docdash": "^0.4.0",
     "eslint": "^4.6.1",
@@ -37,6 +36,7 @@
     "gh-pages": "^1.0.0",
     "jsdoc": "^3.5.5",
     "json": "^9.0.4",
+    "playwright-chromium": "^1.0.1",
     "scratch-vm": "0.2.0-prerelease.20191227164934",
     "tap": "^11.0.0",
     "travis-after-all": "^1.4.4",
diff --git a/test/helper/page-util.js b/test/helper/page-util.js
new file mode 100644
index 000000000..25f5b007e
--- /dev/null
+++ b/test/helper/page-util.js
@@ -0,0 +1,54 @@
+/* global window, VirtualMachine, ScratchStorage, ScratchSVGRenderer */
+/* eslint-env browser */
+
+// Wait for all SVG skins to be loaded.
+// TODO: this is extremely janky and should be removed once vm.loadProject waits for SVG skins to load
+// https://github.com/LLK/scratch-render/issues/563
+window.waitForSVGSkinLoad = renderer => new Promise(resolve => {
+    // eslint-disable-next-line prefer-const
+    let interval;
+
+    const waitInner = () => {
+        let numSVGSkins = 0;
+        let numLoadedSVGSkins = 0;
+        for (const skin of renderer._allSkins) {
+            if (skin.constructor.name !== 'SVGSkin') continue;
+            numSVGSkins++;
+            if (skin._svgRenderer.loaded) numLoadedSVGSkins++;
+        }
+
+        if (numSVGSkins === numLoadedSVGSkins) {
+            clearInterval(interval);
+            resolve();
+        }
+    };
+
+    interval = setInterval(waitInner, 1);
+});
+
+window.loadFileInputIntoVM = (fileInput, vm, render) => {
+    const reader = new FileReader();
+    return new Promise(resolve => {
+        reader.onload = () => {
+            vm.start();
+            vm.loadProject(reader.result)
+                .then(() => window.waitForSVGSkinLoad(render))
+                .then(() => {
+                    resolve();
+                });
+        };
+        reader.readAsArrayBuffer(fileInput.files[0]);
+    });
+};
+
+window.initVM = render => {
+    const vm = new VirtualMachine();
+    const storage = new ScratchStorage();
+
+    vm.attachStorage(storage);
+    vm.attachRenderer(render);
+    vm.attachV2SVGAdapter(new ScratchSVGRenderer.SVGRenderer());
+    vm.attachV2BitmapAdapter(new ScratchSVGRenderer.BitmapAdapter());
+
+    return vm;
+};
diff --git a/test/integration/cpu-render.html b/test/integration/cpu-render.html
index d6d363084..26a79c3f4 100644
--- a/test/integration/cpu-render.html
+++ b/test/integration/cpu-render.html
@@ -2,6 +2,7 @@
     <script src="../../node_modules/scratch-vm/dist/web/scratch-vm.js"></script>
     <script src="../../node_modules/scratch-storage/dist/web/scratch-storage.js"></script>
     <script src="../../node_modules/scratch-svg-renderer/dist/web/scratch-svg-renderer.js"></script>
+    <script src="../helper/page-util.js"></script>
     <!-- note: this uses the BUILT version of scratch-render!  make sure to npm run build -->
     <script src="../../dist/web/scratch-render.js"></script>
 
@@ -17,38 +18,18 @@
         window.devicePixelRatio = 1;
         const gpuCanvas = document.getElementById('test');
         var render = new ScratchRender(gpuCanvas);
-        var vm = new VirtualMachine();
-        var storage = new ScratchStorage();
+        var vm = initVM(render);
 
-        vm.attachStorage(storage);
-        vm.attachRenderer(render);
-        vm.attachV2SVGAdapter(new ScratchSVGRenderer.SVGRenderer());
-        vm.attachV2BitmapAdapter(new ScratchSVGRenderer.BitmapAdapter());
-
-        document.getElementById('file').addEventListener('click', e => {
-            document.body.removeChild(document.getElementById('loaded'));
-        });
-
-        document.getElementById('file').addEventListener('change', e => {
-            const reader = new FileReader();
-            const thisFileInput = e.target;
-            reader.onload = () => {
-                vm.start();
-                vm.loadProject(reader.result)
-                    .then(() => {
-                        // we add a `#loaded` div to our document, the integration suite
-                        // waits for that element to show up to assume the vm is ready
-                        // to play!
-                        const div = document.createElement('div');
-                        div.id='loaded';
-                        document.body.appendChild(div);
-                        vm.greenFlag();
-                        setTimeout(() => {
-                            renderCpu();
-                        }, 1000);
-                    });
-            };
-            reader.readAsArrayBuffer(thisFileInput.files[0]);
+        const fileInput = document.getElementById('file');
+        const loadFile = loadFileInputIntoVM.bind(null, fileInput, vm, render);
+        fileInput.addEventListener('change', e => {
+            loadFile()
+                .then(() => {
+                    vm.greenFlag();
+                    setTimeout(() => {
+                        renderCpu();
+                    }, 1000);
+                });
         });
 
         const cpuCanvas = document.getElementById('cpu');
diff --git a/test/integration/index.html b/test/integration/index.html
index e3d8dd838..114fa5b9d 100644
--- a/test/integration/index.html
+++ b/test/integration/index.html
@@ -2,6 +2,7 @@
     <script src="../../node_modules/scratch-vm/dist/web/scratch-vm.js"></script>
     <script src="../../node_modules/scratch-storage/dist/web/scratch-storage.js"></script>
     <script src="../../node_modules/scratch-svg-renderer/dist/web/scratch-svg-renderer.js"></script>
+    <script src="../helper/page-util.js"></script>
     <!-- note: this uses the BUILT version of scratch-render!  make sure to npm run build -->
     <script src="../../dist/web/scratch-render.js"></script>
 
@@ -15,39 +16,13 @@
 
         var canvas = document.getElementById('test');
         var render = new ScratchRender(canvas);
-        var vm = new VirtualMachine();
-        var storage = new ScratchStorage();
+        var vm = initVM(render);
         var mockMouse = data => vm.runtime.postIOData('mouse', {
             canvasWidth: canvas.width,
             canvasHeight: canvas.height,
             ...data,
         });
 
-        vm.attachStorage(storage);
-        vm.attachRenderer(render);
-        vm.attachV2SVGAdapter(new ScratchSVGRenderer.SVGRenderer());
-        vm.attachV2BitmapAdapter(new ScratchSVGRenderer.BitmapAdapter());
-
-        document.getElementById('file').addEventListener('click', e => {
-            document.body.removeChild(document.getElementById('loaded'));
-        });
-
-        document.getElementById('file').addEventListener('change', e => {
-            const reader = new FileReader();
-            const thisFileInput = e.target;
-            reader.onload = () => {
-                vm.start();
-                vm.loadProject(reader.result)
-                    .then(() => {
-                        // we add a `#loaded` div to our document, the integration suite
-                        // waits for that element to show up to assume the vm is ready
-                        // to play!
-                        const div = document.createElement('div');
-                        div.id='loaded';
-                        document.body.appendChild(div);
-                    });
-            };
-            reader.readAsArrayBuffer(thisFileInput.files[0]);
-        });
+        const loadFile = loadFileInputIntoVM.bind(null, document.getElementById('file'), vm, render);
     </script>
 </body>
diff --git a/test/integration/pick-tests.js b/test/integration/pick-tests.js
index 6d3222e50..4012bcdad 100644
--- a/test/integration/pick-tests.js
+++ b/test/integration/pick-tests.js
@@ -1,29 +1,34 @@
 /* global vm, render, Promise */
-const {Chromeless} = require('chromeless');
+const {chromium} = require('playwright-chromium');
 const test = require('tap').test;
 const path = require('path');
-const chromeless = new Chromeless();
 
 const indexHTML = path.resolve(__dirname, 'index.html');
 const testDir = (...args) => path.resolve(__dirname, 'pick-tests', ...args);
 
-const runFile = (file, action, script) =>
+const runFile = async (file, action, page, script) => {
     // start each test by going to the index.html, and loading the scratch file
-    chromeless.goto(`file://${indexHTML}`)
-        .setFileInput('#file', testDir(file))
-        // the index.html handler for file input will add a #loaded element when it
-        // finishes.
-        .wait('#loaded')
-        .evaluate(`function () {return (${script})(${action});}`)
-;
+    await page.goto(`file://${indexHTML}`);
+    const fileInput = await page.$('#file');
+    await fileInput.setInputFiles(testDir(file));
+
+    await page.evaluate(() =>
+        // `loadFile` is defined on the page itself.
+        // eslint-disable-next-line no-undef
+        loadFile()
+    );
+    return page.evaluate(`(function () {return (${script})(${action});})()`);
+};
 
 // immediately invoked async function to let us wait for each test to finish before starting the next.
 (async () => {
+    const browser = await chromium.launch();
+    const page = await browser.newPage();
 
     const testOperation = async function (name, action, expect) {
         await test(name, async t => {
 
-            const results = await runFile('test-mouse-touch.sb2', action, boundAction => {
+            const results = await runFile('test-mouse-touch.sb2', action, page, boundAction => {
                 vm.greenFlag();
                 const sendResults = [];
 
@@ -97,5 +102,5 @@ const runFile = (file, action, script) =>
     }
 
     // close the browser window we used
-    await chromeless.end();
+    await browser.close();
 })();
diff --git a/test/integration/scratch-tests.js b/test/integration/scratch-tests.js
index f11bc5ba7..bad0774d2 100644
--- a/test/integration/scratch-tests.js
+++ b/test/integration/scratch-tests.js
@@ -1,54 +1,56 @@
 /* global vm, Promise */
-const {Chromeless} = require('chromeless');
+const {chromium} = require('playwright-chromium');
 const test = require('tap').test;
 const path = require('path');
 const fs = require('fs');
-const chromeless = new Chromeless();
 
 const indexHTML = path.resolve(__dirname, 'index.html');
 const testDir = (...args) => path.resolve(__dirname, 'scratch-tests', ...args);
 
-const testFile = file => test(file, async t => {
+const testFile = (file, page) => test(file, async t => {
     // start each test by going to the index.html, and loading the scratch file
-    const says = await chromeless.goto(`file://${indexHTML}`)
-        .setFileInput('#file', testDir(file))
-        // the index.html handler for file input will add a #loaded element when it
-        // finishes.
-        .wait('#loaded')
-        .evaluate(() => {
-            // This function is run INSIDE the integration chrome browser via some
-            // injection and .toString() magic.  We can return some "simple data"
-            // back across as a promise, so we will just log all the says that happen
-            // for parsing after.
-
-            // this becomes the `says` in the outer scope
-            const messages = [];
-            const TIMEOUT = 5000;
-
-            vm.runtime.on('SAY', (_, __, message) => {
-                messages.push(message);
-            });
+    await page.goto(`file://${indexHTML}`);
+    const fileInput = await page.$('#file');
+    await fileInput.setInputFiles(testDir(file));
+    await page.evaluate(() =>
+        // `loadFile` is defined on the page itself.
+        // eslint-disable-next-line no-undef
+        loadFile()
+    );
+    const says = await page.evaluate(() => {
+        // This function is run INSIDE the integration chrome browser via some
+        // injection and .toString() magic.  We can return some "simple data"
+        // back across as a promise, so we will just log all the says that happen
+        // for parsing after.
+
+        // this becomes the `says` in the outer scope
+        const messages = [];
+        const TIMEOUT = 5000;
+
+        vm.runtime.on('SAY', (_, __, message) => {
+            messages.push(message);
+        });
 
-            vm.greenFlag();
-            const startTime = Date.now();
-
-            return Promise.resolve()
-                .then(async () => {
-                    // waiting for all threads to complete, then we return
-                    while (vm.runtime.threads.some(thread => vm.runtime.isActiveThread(thread))) {
-                        if ((Date.now() - startTime) >= TIMEOUT) {
-                            // if we push the message after end, the failure from tap is not very useful:
-                            // "not ok test after end() was called"
-                            messages.unshift(`fail Threads still running after ${TIMEOUT}ms`);
-                            break;
-                        }
-
-                        await new Promise(resolve => setTimeout(resolve, 50));
+        vm.greenFlag();
+        const startTime = Date.now();
+
+        return Promise.resolve()
+            .then(async () => {
+                // waiting for all threads to complete, then we return
+                while (vm.runtime.threads.some(thread => vm.runtime.isActiveThread(thread))) {
+                    if ((Date.now() - startTime) >= TIMEOUT) {
+                        // if we push the message after end, the failure from tap is not very useful:
+                        // "not ok test after end() was called"
+                        messages.unshift(`fail Threads still running after ${TIMEOUT}ms`);
+                        break;
                     }
 
-                    return messages;
-                });
-        });
+                    await new Promise(resolve => setTimeout(resolve, 50));
+                }
+
+                return messages;
+            });
+    });
 
     // Map string messages to tap reporting methods. This will be used
     // with events from scratch's runtime emitted on block instructions.
@@ -103,13 +105,16 @@ const testFile = file => test(file, async t => {
 
 // immediately invoked async function to let us wait for each test to finish before starting the next.
 (async () => {
+    const browser = await chromium.launch();
+    const page = await browser.newPage();
+
     const files = fs.readdirSync(testDir())
         .filter(uri => uri.endsWith('.sb2') || uri.endsWith('.sb3'));
 
     for (const file of files) {
-        await testFile(file);
+        await testFile(file, page);
     }
 
     // close the browser window we used
-    await chromeless.end();
+    await browser.close();
 })();