diff --git a/extensions/integration_testing/downloads/allBlocks.sb3 b/extensions/integration_testing/downloads/allBlocks.sb3 new file mode 100644 index 000000000..8ac598478 Binary files /dev/null and b/extensions/integration_testing/downloads/allBlocks.sb3 differ diff --git a/extensions/integration_testing/downloads/comboBlocks.sb3 b/extensions/integration_testing/downloads/comboBlocks.sb3 new file mode 100644 index 000000000..369e623f8 Binary files /dev/null and b/extensions/integration_testing/downloads/comboBlocks.sb3 differ diff --git a/extensions/integration_testing/node_script.ts b/extensions/integration_testing/node_script.ts new file mode 100644 index 000000000..4971158c7 --- /dev/null +++ b/extensions/integration_testing/node_script.ts @@ -0,0 +1,84 @@ +const { exec } = require('child_process'); + +let devProcess: any; + +async function runDevScript(param: string): Promise { + return new Promise((resolve, reject) => { + devProcess = exec(`cd ../.. && pnpm dev -i ${param}`, (error: any, stdout: any, stderr: any) => { + if (error) { + console.error(`Error running dev script: ${error}`); + reject(error); + } else { + console.log(`Dev script output: ${stdout}`); + resolve(); + } + }); + + devProcess.stdout.on('data', (data: any) => { + console.log(`stdout: ${data}`); + }); + + devProcess.stderr.on('data', (data: any) => { + console.error(`stderr: ${data}`); + }); + }); + } + + async function runPlaywrightScript(scriptName: string) { + return new Promise((resolve, reject) => { + const command = `npx ts-node ${scriptName}`; + const playwrightProcess = exec(command, (error: any, stdout: any, stderr: any) => { + if (error) { + console.error(`Error running script: ${error.message}`); + reject(error); + } + if (stderr) { + console.error(`Error output: ${stderr}`); + reject(stderr); + } + console.log(`Script output: ${stdout}`); + resolve(stdout); + }); + + playwrightProcess.stdout.on('data', (data: any) => { + console.log(`stdout: ${data}`); + }); + + playwrightProcess.stderr.on('data', (data: any) => { + console.error(`stderr: ${data}`); + }); + }); +} + + +async function runPlaywrightTasks() { + try { + await runPlaywrightScript('playwright_save_all.ts'); + await runPlaywrightScript('playwright_save_combo.ts'); + await runPlaywrightScript('playwright_load.ts'); + process.exit(0); + } catch (error) { + console.error('Error during Playwright tasks:', error); + } + } + +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.error('Please provide a parameter.'); + return; + } + + const param = args[0]; + + try { + runDevScript(param); + console.log('Dev script started successfully'); + runPlaywrightTasks(); + } catch (error) { + console.error('Error running script:', error); + } + } + + main(); diff --git a/extensions/integration_testing/playwright_load.ts b/extensions/integration_testing/playwright_load.ts new file mode 100644 index 000000000..6656391be --- /dev/null +++ b/extensions/integration_testing/playwright_load.ts @@ -0,0 +1,211 @@ +import { chromium, Browser, BrowserContext, Page, ElementHandle } from 'playwright'; +import * as path from 'path'; + +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +(async () => { + // Launch a browser instance + const browser = await chromium.launch({ headless: false }); // Set headless: false to see the browser actions + const context = await browser.newContext({ + permissions: ['camera'], + acceptDownloads: true + }); + const page = await context.newPage(); + + await page.goto('http://localhost:8602/'); + await page.click('text=File'); + + // UPLOAD ALL THE BLOCKS + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.click('text=Load from your computer'), + ]); + + const filePath = path.resolve(__dirname, 'downloads', 'allBlocks.sb3'); + await fileChooser.setFiles(filePath); + + // TEST 1: PROJECT CANNOT LOAD + page.on('dialog', async dialog => { + console.log(`Dialog message: ${dialog.message()}`); + if (dialog.message().includes("The project file that was selected failed to load.")) { + console.log("Invalid opcode found.") + console.log("TEST FAILED"); + } + await dialog.accept(); + }); + + // TEST 2: RUNTIME ERRORS + page.on('pageerror', (error) => { + console.log(`Uncaught runtime error: ${error.message}`); + console.log + }); + + // TEST 3: INCOMPATIBLE FUNCTION TYPES + let incompatibleTypes = false; + page.on('console', (message) => { + if (message.type() === 'error') { + if (message.text().includes('gui Attempt to connect incompatible types.')) { + console.log("Incompatible function types detected.") + console.log("TEST FAILED"); + incompatibleTypes = true; + } + } + }); + + //await page.waitForSelector('.blocklyBlockCanvas', { timeout: 120000 }); + await page.waitForTimeout(2000); + await page.click('text=File'); + const [fileChooser2] = await Promise.all([ + page.waitForEvent('filechooser'), + page.click('text=Load from your computer'), + ]); + + await fileChooser2.setFiles(filePath); + //await page.waitForSelector('.blocklyBlockCanvas', { timeout: 120000 }); + await page.waitForTimeout(2000); + + + // Wait until blockly canvas has children + await page.$eval('.blocklyBlockCanvas', (element) => { + return element.children.length > 0; + }); + + // Zoom all the way out + const blocklyZoomElement = await page.waitForSelector('.blocklyZoom', { timeout: 5000 }); + const boundingBox = await blocklyZoomElement.boundingBox(); + if (boundingBox) { + const x = boundingBox.x + boundingBox.width / 2; + const y = boundingBox.y + boundingBox.height / 2; + await page.mouse.click(x, y); + await page.mouse.click(x, y); + await page.mouse.click(x, y); + await page.mouse.click(x, y); + await page.mouse.click(x, y); + await page.mouse.click(x, y); + console.log('Successfully clicked in the middle of the blocklyZoom element.'); + } + + // Collect all IDs that aren't arguments + var dataIds = await page.$eval('.blocklyBlockCanvas', (blockCanvas) => { + const elements = blockCanvas.querySelectorAll('[data-id]'); + const ids: any[] = []; + elements.forEach((element: any) => { + const opcode = element.getAttribute('data-opcode'); + if (opcode && opcode !== "math_number" && opcode !== "text" && !opcode.includes("_menu_")) { + const dataId = element.getAttribute('data-id'); + if (dataId) { + const shape = element.getAttribute('data-shapes'); + const opcode = element.getAttribute('data-opcode'); + ids.push({ id: dataId, shape, opcode }); + } + } + }); + return ids; + }); + + // Collect all the arguments from the IDs + const blocks: any[] = []; + for (const id of dataIds) { + + var block = await page.$eval('.blocklyBlockCanvas', (blockCanvas: any, id: any) => { + const element = blockCanvas.querySelector(`[data-id="${id}"]`); + const argumentElements = (element as any).querySelectorAll('[data-argument-type]'); + + const argumentList = Array.from(argumentElements).map(argElement => { + const argumentType = (argElement as any).getAttribute('data-argument-type'); + const transform = (argElement as any).getAttribute('transform'); + const match = transform.match(/translate\(([\d.]+),\s*([\d.]+)\)/); + let xValue = 0.0; + if (match && match.length > 1) { + xValue = parseFloat(match[1]); + } + return { + type: argumentType, + xValue: String(xValue) + } + }); + const filteredArgumentList = argumentList.filter((arg, index, self) => { + // TEST 4: NEW ARGUMENTS ADDED + if (arg.type == 'round') { + // Checking to see if the "round" argument type has another value in it + const otherValueIncluded = self.some((otherArg: any, otherIndex: any) => otherIndex !== index && otherArg.xValue === arg.xValue); + if (!otherValueIncluded) { + console.log("New argument detected."); + console.log("TEST FAILED"); + return true; + } else { + return false; + } + } + return true; + }); + // Sorting the argument types to get them in order + const sortedArgumentList = filteredArgumentList.sort((a: any, b: any) => a.xValue - b.xValue).map(element => element.type); + + return { + id: (element as any).getAttribute('data-id'), + arguments: sortedArgumentList, + opcode: (element as any).getAttribute('data-opcode'), + }; + }, id.id); + blocks.push(block); + } + + // Helper function: checking if arrays match + function arraysMatch(arr1: string[], arr2: string[]): boolean { + if (arr1.length !== arr2.length) return false; + return arr1.every((value, index) => value === arr2[index]); + } + + // Now collect the argument order for the new version block values + for (const block of blocks) { + const selector = `[data-id="${block.opcode}"]`; + const elementHandle = await page.$(selector); + + if (elementHandle) { + const argumentElements = await elementHandle.$$('[data-argument-type]'); + + const argumentList: any[] = await Promise.all(argumentElements.map(async argHandle => { + const argumentType = await argHandle.getAttribute('data-argument-type'); + const transform = await argHandle.getAttribute('transform'); + let xValue = 0.0; + + if (transform) { + const match = transform.match(/translate\(([\d.]+),\s*([\d.]+)\)/); + if (match && match.length > 1) { + xValue = parseFloat(match[1]); // Adjusted to get the y value instead of x value + } + } + return { + type: argumentType, + xValue: xValue + }; + })); + // TEST 5: CHECKING ARGUMENT ORDER + const sortedArgumentList = argumentList.sort((a: any, b: any) => a.xValue - b.xValue).map(element => element.type); + if (!arraysMatch(block.arguments, sortedArgumentList)) { + console.log(block.arguments); + console.log(sortedArgumentList); + console.log("Argument order does not match.") + console.log("TEST FAILED") + } + } + } + + await page.waitForTimeout(2000); + await page.reload(); + + // TEST 6: CHECKING WITH CONNECTED BLOCKS + await page.click('text=File'); + const [fileChooser3] = await Promise.all([ + page.waitForEvent('filechooser'), + page.click('text=Load from your computer'), + ]); + + const filePath2 = path.resolve(__dirname, 'downloads', 'comboBlocks.sb3'); + console.log(`Uploading file from: ${filePath2}`); + await fileChooser3.setFiles(filePath2); + await page.waitForTimeout(5000); + await browser.close(); +})(); + \ No newline at end of file diff --git a/extensions/integration_testing/playwright_save_all.ts b/extensions/integration_testing/playwright_save_all.ts new file mode 100644 index 000000000..a5057f228 --- /dev/null +++ b/extensions/integration_testing/playwright_save_all.ts @@ -0,0 +1,176 @@ +import { chromium } from 'playwright'; +import { expect } from 'playwright/test'; +import * as path from 'path'; + +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +(async () => { + // Launch a browser instance + const browser = await chromium.launch({ headless: false }); // Set headless: false to see the browser actions + const context = await browser.newContext({ + permissions: ['camera'], + acceptDownloads: true + }); + const page = await context.newPage(); + let connected = false; + for (let i = 0; i < 120; i++) { // Retry up to 10 times + try { + await page.goto('http://localhost:8602/', { waitUntil: 'networkidle', timeout: 6000 }); + connected = true; + break; + } catch (e) { + console.log('Connection failed, retrying...'); + await delay(5000); // Wait for 5 seconds before retrying + } + } + + // All the extensions that have been included + const includedExtensions = [ + "PRG Microbit Robot", + "PRG Gizmo Robot", + "PRG Arduino Robot", + "Music", + "Pen", + "Video Sensing", + "Text to Speech", + "Translate", + "Makey Makey", + "micro:bit", + "LEGO MINDSTORMS EV3", + "LEGO BOOST", + "LEGO Education WeDo 2.0", + "Go Direct Force & Acceleration" + ]; + + // Declaring constants + const startX = 400; + const startY = 130; + + // Wait for the editor to load + //await page.waitForSelector('.blocklyBlockCanvas'); + await page.waitForTimeout(3000); + // Add the extension + const addExtension = await page.$('[title="Add Extension"]'); + if (addExtension) { + await addExtension.click(); + const parentElement = await page.$('.library_library-scroll-grid_1jyXm.library_withFilterBar_26Opm'); + if (parentElement) { + const childElements = await parentElement.$$('.library-item_library-item_1DcMO.library-item_featured-item_3V2-t.library-item_library-item-extension_3xus9'); // Adjust the selector to match the child element type + const elementsWithSpan: any = []; + + // Collect the extensions that are already included + for (const child of childElements) { + const spanElement = await child.$('span:first-of-type'); + if (spanElement) { + const textContent = await child.textContent(); + if (includedExtensions.some(phrase => textContent?.includes(phrase))) { + elementsWithSpan.push(child); + } + } + } + // Find the extension that was added + var chosenElements: any = []; + for (const child of childElements) { + if (!elementsWithSpan.includes(child)) { + chosenElements.push(child); + } + } + + if (chosenElements[0]) { // The added extension + const firstSpan = await chosenElements[0].$('span:first-of-type'); + // Collect the title of the added extension + if (firstSpan) { + const spanText = await firstSpan.textContent(); + await chosenElements[0].click(); + const elementLocator = page.locator(`.blocklyFlyoutLabelText:has-text("${spanText}")`); + await elementLocator.waitFor(); + await page.waitForTimeout(1000); + // Find all the blocks under the added extension + const elements = await page.$$(`[data-category="${spanText}"]`); + + // Zoom all the way out + const blocklyZoomElement = await page.waitForSelector('.blocklyZoom', { timeout: 5000 }); + const boundingBox = await blocklyZoomElement.boundingBox(); + if (boundingBox) { + const x = boundingBox.x + boundingBox.width / 2; + const y = boundingBox.y + boundingBox.height / 2; + await page.mouse.click(x, y); + await page.mouse.click(x, y); + await page.mouse.click(x, y); + await page.mouse.click(x, y); + await page.mouse.click(x, y); + await page.mouse.click(x, y); + } + + // Starting values + let tempScroll = 0; + let yOffset = startY; + let xOffset = startX; + // Add each element + for (const element of elements) { + // Reset the scroll if needed + if (tempScroll > 0) { + await page.mouse.move(100, 100); + await page.mouse.wheel(0, -1*tempScroll); + await page.waitForTimeout(500); + tempScroll = 0; + } + const boundingBox1 = await element.boundingBox(); + // Check if we have to scroll down to find the element + if (boundingBox1) { + let found = false; + while (!found) { + try { + await element.hover({ timeout: 100 }); + found = true; + await page.waitForTimeout(500); + } catch (error) { + await page.mouse.move(100, 100); + await page.mouse.wheel(0, boundingBox1.height); + tempScroll = tempScroll + boundingBox1.height; + } + } + } + + const boundingBox = await element.boundingBox(); + if (boundingBox) { + // Move the mouse to the center of the element + yOffset += 20; + await page.mouse.move(boundingBox.x + boundingBox.width / 2, boundingBox.y + boundingBox.height / 2); + await page.mouse.down(); + let x = xOffset + boundingBox.width / 2; + await page.mouse.move(x, yOffset); + await page.mouse.up(); + + // If we've gone all the way to the right, start again + xOffset = xOffset + 20; + if (xOffset > 600) { + xOffset = startX; + } + // Scroll if we've reached the bottom of the workspace + if (yOffset > 600) { + await page.mouse.move(500, 300); + await page.mouse.wheel(0, 10); + yOffset = yOffset - 15; + } + } + } + } + } + } + } + + // Save the project + const downloadPath = path.resolve(__dirname, 'downloads'); + await page.click('text=File'); + await page.click('text=Save to your computer'); + const [download] = await Promise.all([ + page.waitForEvent('download'), + ]); + + // Save the download to allBlocks.sb3 + await download.saveAs(path.join(downloadPath, "allBlocks.sb3")); + console.log(`File downloaded to: ${path.join(downloadPath, "allBlocks.sb3")}`); + await page.waitForTimeout(5000); + await browser.close(); +})(); diff --git a/extensions/integration_testing/playwright_save_combo.ts b/extensions/integration_testing/playwright_save_combo.ts new file mode 100644 index 000000000..0df824b89 --- /dev/null +++ b/extensions/integration_testing/playwright_save_combo.ts @@ -0,0 +1,335 @@ +import { chromium } from 'playwright'; +import { expect } from 'playwright/test'; +import * as path from 'path'; + +(async () => { + // Launch a browser instance + const browser = await chromium.launch({ headless: false }); // Set headless: false to see the browser actions + const context = await browser.newContext({ + permissions: ['camera'], + acceptDownloads: true + }); + const page = await context.newPage(); + await page.goto('http://localhost:8602/'); + + // All the extensions that are already there + const includedExtensions = [ + "PRG Microbit Robot", + "PRG Gizmo Robot", + "PRG Arduino Robot", + "Music", + "Pen", + "Video Sensing", + "Text to Speech", + "Translate", + "Makey Makey", + "micro:bit", + "LEGO MINDSTORMS EV3", + "LEGO BOOST", + "LEGO Education WeDo 2.0", + "Go Direct Force & Acceleration" + ]; + + + //await page.waitForSelector('.blocklyWorkspace'); + await page.waitForTimeout(2000); + + // Make sure no blocks are dragged out of the workspace + function boundY(y: any) { + if (y > 600) { + return 600; + } + return y; + } + + // Declaring constants + const startX = 400; + const startY = 130; + const scale = 0.35; + + // Add the extension + const addExtension = await page.$('[title="Add Extension"]'); + if (addExtension) { + await addExtension.click(); + const parentElement = await page.$('.library_library-scroll-grid_1jyXm.library_withFilterBar_26Opm'); + + if (parentElement) { + const childElements = await parentElement.$$('.library-item_library-item_1DcMO.library-item_featured-item_3V2-t.library-item_library-item-extension_3xus9'); // Adjust the selector to match the child element type + const elementsWithSpan: any = []; + // Collect all the elements that haven't been added + for (const child of childElements) { + const spanElement = await child.$('span:first-of-type'); + if (spanElement) { + const textContent = await child.textContent(); + if (includedExtensions.some(phrase => textContent?.includes(phrase))) { + elementsWithSpan.push(child); + } + } + } + // Now find the elements that HAVE been added + var chosenElements: any = []; + for (const child of childElements) { + if (!elementsWithSpan.includes(child)) { + chosenElements.push(child); + } + } + + // This element has been added + if (chosenElements[0]) { + // Collect the title of the added extension + const firstSpan = await chosenElements[0].$('span:first-of-type'); + if (firstSpan) { + const spanText = await firstSpan.textContent(); + await chosenElements[0].click(); + + const elementLocator = page.locator(`.blocklyFlyoutLabelText:has-text("${spanText}")`); + await elementLocator.waitFor(); + await page.waitForTimeout(1000); + + // Now collect the element types + const hatElements = await page.$$(`[data-category="${spanText}"][data-shapes="hat"]`); + const stackElements = await page.$$(`[data-category="${spanText}"][data-shapes="stack"]`); + const elements = await page.$$(`[data-category="${spanText}"]:not([data-shapes="stack"]):not([data-shapes="hat"])`); + + const blocklyZoomElement = await page.waitForSelector('.blocklyZoom', { timeout: 5000 }); + + // Zoom all the way out + const boundingBox = await blocklyZoomElement.boundingBox(); + if (boundingBox) { + const x = boundingBox.x + boundingBox.width / 2; + const y = boundingBox.y + boundingBox.height / 2; + await page.mouse.click(x, y); + await page.mouse.click(x, y); + await page.mouse.click(x, y); + await page.mouse.click(x, y); + await page.mouse.click(x, y); + await page.mouse.click(x, y); + } + + let stackMax = 0; + // If there are hat elements + if (hatElements.length > 0) { + let tempScroll = 0; + + let xOffset = startX; + for (const hatElement of hatElements) { + + let yOffset = startY; + + // Reset the scroll position + if (tempScroll > 0) { + await page.mouse.move(100, 100); + await page.mouse.wheel(0, -1*tempScroll); + await page.waitForTimeout(500); + tempScroll = 0; + } + + // Check if we have to scroll down to find the element + const boundingBox1 = await hatElement.boundingBox(); + if (boundingBox1) { + let found = false; + while (!found) { + try { + await hatElement.hover({ timeout: 100 }); + found = true; + await page.waitForTimeout(500); + } catch (error) { + await page.mouse.move(100, 100); + await page.mouse.wheel(0, boundingBox1.height); + tempScroll = tempScroll + boundingBox1.height; + } + } + } + + // Move the hat element + const hatBoundingBox = await hatElement.boundingBox(); + if (hatBoundingBox) { + await page.mouse.move(hatBoundingBox.x + hatBoundingBox.width / 2, hatBoundingBox.y + hatBoundingBox.height / 2); + await page.mouse.down(); + await page.mouse.move(xOffset+hatBoundingBox.width/2, boundY(yOffset)); + await page.mouse.up(); + yOffset += hatBoundingBox.height * scale; + // Add each stack element underneath the hat element + for (const stackElement of stackElements) { + // Reset the scroll if needed + if (tempScroll > 0) { + await page.mouse.move(100, 100); + await page.mouse.wheel(0, -1*tempScroll); + await page.waitForTimeout(500); + tempScroll = 0; + } + const boundingBox1 = await stackElement.boundingBox(); + // Check if we have to scroll down to find the element + if (boundingBox1) { + let found = false; + while (!found) { + try { + await stackElement.hover({ timeout: 100 }); + found = true; + await page.waitForTimeout(500); + } catch (error) { + await page.mouse.move(100, 100); + await page.mouse.wheel(0, boundingBox1.height); + tempScroll = tempScroll + boundingBox1.height; + } + } + + } + // Move the stack element under the hat element + const stackBoundingBox = await stackElement.boundingBox(); + if (stackBoundingBox) { + await page.mouse.move(stackBoundingBox.x + stackBoundingBox.width / 2, stackBoundingBox.y + stackBoundingBox.height / 2); + await page.mouse.down(); + await page.mouse.move(xOffset+stackBoundingBox.width / 2, boundY(yOffset)); + await page.mouse.up(); + // Set the max Y value for the remaining blocks + if (yOffset > stackMax) { + stackMax = yOffset; + } + yOffset += stackBoundingBox.height * scale; + } + } + // Move to the right for each stack + xOffset = xOffset + 50; + // Reset the scroll if needed + if (tempScroll > 0) { + await page.mouse.move(100, 100); + await page.mouse.wheel(0, -1*tempScroll); + await page.waitForTimeout(500); + tempScroll = 0; + } + } + } + } else { + // If there are no hat elements, create one stack + let tempScroll = 0; + let yOffset = startY; + for (const element of stackElements) { + // Reset the scroll if needed + if (tempScroll > 0) { + await page.mouse.move(100, 100); + await page.mouse.wheel(0, -1*tempScroll); + await page.waitForTimeout(500); + tempScroll = 0; + } + const boundingBox1 = await element.boundingBox(); + // Check if we have to scroll down to find the element + if (boundingBox1) { + let found = false; + while (!found) { + try { + await element.hover({ timeout: 100 }); + found = true; + } catch (error) { + await page.mouse.move(100, 100); + await page.mouse.wheel(0, boundingBox1.height); + tempScroll = tempScroll + boundingBox1.height; + } + } + } + const boundingBox = await element.boundingBox(); + // Move the stack element to the right + if (boundingBox) { + yOffset += boundingBox.height * scale; + await page.mouse.move(boundingBox.x + boundingBox.width / 2, boundingBox.y + boundingBox.height / 2); + await page.mouse.down(); + await page.mouse.move(startX+boundingBox.width / 2, boundY(yOffset)); + await page.mouse.up(); + // Set the max Y value for the remaining blocks + if (yOffset > stackMax) { + stackMax = yOffset; + } + } + } + // Reset the scroll if needed + if (tempScroll > 0) { + await page.mouse.move(100, 100); + await page.mouse.wheel(0, -1*tempScroll); + await page.waitForTimeout(500); + tempScroll = 0; + } + } + + // Collect all the input elements + const firstBlocklyBlockCanvas = await page.$('.blocklyBlockCanvas'); + const inputElements = firstBlocklyBlockCanvas + ? await firstBlocklyBlockCanvas.$$('.blocklyEditableText:not([data-argument-type="dropdown"]):not(:has(.blocklyDropdownText))') + : []; + + // Set the starting y value for the input elements + var maxY = stackMax+5; + + let inputIndex = 0; + let inputOffset = 0; + let tempScroll = 0; + + for (const element of elements) { + // Reset the scroll if needed + if (tempScroll > 0) { + await page.mouse.move(100, 100); + await page.mouse.wheel(0, -1*tempScroll); + await page.waitForTimeout(500); + tempScroll = 0; + } + // Check if we need to scroll down to find the block + const boundingBox1 = await element.boundingBox(); + if (boundingBox1) { + let found = false; + while (!found) { + try { + await element.hover({ timeout: 100 }); + found = true; + } catch (error) { + await page.mouse.move(100, 100); + await page.mouse.wheel(0, boundingBox1.height); + tempScroll = tempScroll + boundingBox1.height; + } + } + } + // Move the input element + const boundingBox = await element.boundingBox(); + if (boundingBox) { + await page.mouse.move(boundingBox.x + boundingBox.width / 2, boundingBox.y + boundingBox.height / 2); + await page.mouse.down(); + var moveX: any; + var moveY: any; + + if (inputIndex >= inputElements.length) { // If there are input boxes left to move to + inputOffset = inputOffset + boundingBox.height / 2; + moveX = startX + (boundingBox.width / 2); + moveY = maxY + inputOffset; + } else { // Otherwise, move below the stacks + const inputBoundingBox = await inputElements[inputIndex].boundingBox(); + if (inputBoundingBox) { + moveX = inputBoundingBox.x + (boundingBox.width / 2); + moveY = inputBoundingBox.y + (boundingBox.height / 2); + } + } + + await page.mouse.move(moveX, boundY(moveY)); + await page.mouse.up(); + inputIndex = inputIndex + 1; + await page.waitForTimeout(1000); + + // Increment the yOffset for the next element + } + } + } + } + } + } + + // Save the project + const downloadPath = path.resolve(__dirname, 'downloads'); + await page.click('text=File'); + await page.click('text=Save to your computer'); + const [download] = await Promise.all([ + page.waitForEvent('download'), + ]); + + // Save the download to comboBlocks + await download.saveAs(path.join(downloadPath, "comboBlocks.sb3")); + console.log(`File downloaded to: ${path.join(downloadPath, "comboBlocks.sb3")}`); + await page.waitForTimeout(5000); + await browser.close(); +})(); diff --git a/extensions/src/common/extension/decorators/blocks.ts b/extensions/src/common/extension/decorators/blocks.ts index deb608fb9..0cafcd24c 100644 --- a/extensions/src/common/extension/decorators/blocks.ts +++ b/extensions/src/common/extension/decorators/blocks.ts @@ -1,7 +1,7 @@ import type BlockUtility from "$scratch-vm/engine/block-utility"; import { TypedClassDecorator, TypedGetterDecorator, TypedMethodDecorator, TypedSetterDecorator } from "."; import { BlockType } from "$common/types/enums"; -import { BlockMetadata, ScratchArgument, Argument, NoArgsBlock } from "$common/types"; +import { BlockMetadata, ScratchArgument, Argument, NoArgsBlock, Config } from "$common/types"; import { getImplementationName } from "../mixins/base/scratchInfo/index"; import { ExtensionInstance } from ".."; import { isFunction, isString, tryCreateBundleTimeEvent } from "$common/utils"; @@ -55,36 +55,44 @@ export const setAccessorPrefix = "__setter__"; * @returns A manipulated version of the original method that is */ + export function block< const This extends ExtensionInstance, const Args extends any[], const Return, const Fn extends (...args: Args) => Return, - const TRemoveUtil extends any[] = Args extends [...infer R extends any[], BlockUtility] ? R : Args, + const TRemoveUtil extends any[] = Args extends [...infer R extends any[], BlockUtility] ? R : Args > ( blockInfoOrGetter: (BlockMetadata<(...args: TRemoveUtil) => Return> | ((this: This, self: This) => BlockMetadata<(...args: TRemoveUtil) => Return>)) - ): TypedMethodDecorator Return> { + , versions?: Config): TypedMethodDecorator Return> { return function (this: This, target: (this: This, ...args: Args) => Return, context: ClassMethodDecoratorContext) { - const opcode = target.name; - const internalFuncName = getImplementationName(opcode); - // could add check for if this block is meant for scratch - context.addInitializer(function () { this.pushBlock(opcode, blockInfoOrGetter, target) }); - - const isProbableAtBundleTime = !isFunction(blockInfoOrGetter); - if (isProbableAtBundleTime) { - const { type } = blockInfoOrGetter; - blockBundleEvent?.fire({ - methodName: opcode, - args: extractArgs(blockInfoOrGetter).map(a => isString(a) ? a : a.type), - // is 'any' an issue? Likely! - returns: type === "command" ? "void" : type === "Boolean" ? "bool" : "any", - scratchType: blockInfoOrGetter.type - }); - } - - return (function () { return this[internalFuncName].call(this, ...arguments) }); + var opcode = target.name; + const internalFuncName = getImplementationName(opcode); + // could add check for if this block is meant for scratch + if (versions) { + context.addInitializer(function () { this.pushBlock(opcode, blockInfoOrGetter, target, versions) }); + } else { + context.addInitializer(function () { this.pushBlock(opcode, blockInfoOrGetter, target) }); + } + + + const isProbableAtBundleTime = !isFunction(blockInfoOrGetter); + if (isProbableAtBundleTime) { + const { type } = blockInfoOrGetter; + blockBundleEvent?.fire({ + methodName: opcode, + args: extractArgs(blockInfoOrGetter).map(a => isString(a) ? a : a.type), + // is 'any' an issue? Likely! + returns: type === "command" ? "void" : type === "Boolean" ? "bool" : "any", + scratchType: blockInfoOrGetter.type + }); + } + return (function () { return this[internalFuncName].call(this, ...arguments) }); + + + }; } diff --git a/extensions/src/common/extension/decorators/newBlocks.ts b/extensions/src/common/extension/decorators/newBlocks.ts index e4069f657..fca4c6734 100644 --- a/extensions/src/common/extension/decorators/newBlocks.ts +++ b/extensions/src/common/extension/decorators/newBlocks.ts @@ -1,15 +1,20 @@ -import { BlockMetadata, Argument, ReturnTypeByBlockType, ScratchBlockType, ToArguments } from "$common/types"; +import { BlockMetadata, Config, Argument, ReturnTypeByBlockType, ScratchBlockType, ToArguments } from "$common/types"; import { block } from "$common/extension/decorators/blocks"; import { ExtensionInstance } from ".."; import { TypedMethodDecorator } from "."; import type BlockUtilityWithID from "$scratch-vm/engine/block-utility"; +export const scratch = { + reporter: makeDecorator("reporter"), + command: makeDecorator("command"), +} + const process = (type: ScratchBlockType, strings: TemplateStringsArray, ...args: any[]) => { if (args.length === 0) return { type, text: strings[0], }; const text = (...placeholders: any[]) => strings.map((str, i) => `${str}${placeholders[i] ?? ""}`).join(""); if (args.length === 1) return { type, text, arg: args[0] }; - return { type, text, args }; + return { type, text, args }; } export function makeDecorator(type: T): TemplateEngine["execute"] { @@ -20,11 +25,26 @@ export function makeDecorator(type: T): TemplateEngi const input: any = typeof builderOrStrings == "function" ? (instance) => builderOrStrings(instance, process.bind(null, type)) : process(type, builderOrStrings, ...args); - return block(input)(target, context); + if (target.config) { + return block(input, target.config)(target, context); + } else { + return block(input)(target, context); + } + } } } +export function scratchVersions(config: Config) { + return function( + target, + context + ) { + target.config = config; + return target; + } + }; + namespace Utility { export type TaggedTemplate = (strings: TemplateStringsArray, ...args: Args) => Return; } @@ -68,7 +88,4 @@ interface TemplateEngine { } -export const scratch = { - reporter: makeDecorator("reporter"), - command: makeDecorator("command"), -} \ No newline at end of file + diff --git a/extensions/src/common/extension/mixins/base/scratchInfo/index.ts b/extensions/src/common/extension/mixins/base/scratchInfo/index.ts index d95de0f7c..b95c9952f 100644 --- a/extensions/src/common/extension/mixins/base/scratchInfo/index.ts +++ b/extensions/src/common/extension/mixins/base/scratchInfo/index.ts @@ -1,6 +1,7 @@ import { castToType } from "$common/cast"; import CustomArgumentManager from "$common/extension/mixins/configurable/customArguments/CustomArgumentManager"; import { ArgumentType, BlockType } from "$common/types/enums"; +import { Config } from "$common/types" import { BlockOperation, ValueOf, Menu, ExtensionMetadata, ExtensionBlockMetadata, ExtensionMenuMetadata, DynamicMenu, BlockMetadata, BlockUtilityWithID, } from "$common/types"; import { registerButtonCallback } from "$common/ui"; import { isString, typesafeCall, } from "$common/utils"; @@ -23,6 +24,7 @@ const nonBlockContextError = "Block method was not given a block utility, and th const checkForBlockContext = (blockUtility: BlockUtilityWithID) => isBlockUtilityWithID(blockUtility) ? void 0 : console.error(nonBlockContextError); + /** * Wraps a blocks operation so that the arguments passed from Scratch are first extracted and then passed as indices in a parameter array. * @param _this What will be bound to the 'this' context of the underlying operation @@ -70,7 +72,7 @@ export const wrapOperation = ( * @see https://www.typescriptlang.org/docs/handbook/mixins.html */ export default function (Ctor: CustomizableExtensionConstructor) { - type BlockEntry = { definition: BlockDefinition, operation: BlockOperation }; + type BlockEntry = { definition: BlockDefinition, operation: BlockOperation, versions: Config }; type BlockMap = Map; abstract class ScratchExtension extends Ctor { private readonly blockMap: BlockMap = new Map(); @@ -84,9 +86,15 @@ export default function (Ctor: CustomizableExtensionConstructor) { * @param definition * @param operation */ - pushBlock(opcode: string, definition: BlockDefinition, operation: BlockOperation) { + // add functions parameter here + pushBlock(opcode: string, definition: BlockDefinition, operation: BlockOperation, versions?: Config) { if (this.blockMap.has(opcode)) throw new Error(`Attempt to push block with opcode ${opcode}, but it was already set. This is assumed to be a mistake.`) - this.blockMap.set(opcode, { definition, operation } as BlockEntry); + if (versions) { + this.blockMap.set(opcode, { definition, operation, versions} as BlockEntry); + } else { + this.blockMap.set(opcode, { definition, operation, versions: [] } as BlockEntry); + } + } protected getInfo(): ExtensionMetadata { @@ -103,7 +111,7 @@ export default function (Ctor: CustomizableExtensionConstructor) { private convertToInfo(details: [opcode: string, entry: BlockEntry]) { const [opcode, entry] = details; - const { definition, operation } = entry; + const { definition, operation, versions } = entry; // Utilize explicit casting to appease test framework's typechecker const block = isBlockGetter(definition) @@ -119,7 +127,7 @@ export default function (Ctor: CustomizableExtensionConstructor) { const displayText = convertToDisplayText(opcode, text, args); const argumentsInfo = convertToArgumentInfo(opcode, args, menus); - const info: ExtensionBlockMetadata = { opcode, text: displayText, blockType: type, arguments: argumentsInfo }; + const info: ExtensionBlockMetadata = { opcode, text: displayText, blockType: type, arguments: argumentsInfo, versions }; if (type === BlockType.Button) { const buttonID = getButtonID(id, opcode); diff --git a/extensions/src/common/types/framework/blocks.ts b/extensions/src/common/types/framework/blocks.ts index 3dbfa3d6b..7b07b6c22 100644 --- a/extensions/src/common/types/framework/blocks.ts +++ b/extensions/src/common/types/framework/blocks.ts @@ -9,8 +9,23 @@ export type ButtonBlock = () => InternalButtonKey; export type BlockMetadata< Fn extends BlockOperation, + TFunctions extends Array<(...args: any[]) => any> = [], TParameters extends any[] = Parameters extends [...infer R, BlockUtility] ? R : Parameters -> = Type> & Text & Arguments; +> = Type> & Text & Arguments & { + optionalFunctions?: TFunctions; +}; + +export type ArgTransformer = (...args: any[]) => any[]; + +export type BlockOptions = { + transform?: ArgTransformer; + type?: { [key: string]: string }; + name?: { [key: string]: string }; +}; + +export type Config = { + [index: number]: ArgTransformer | BlockOptions; +}; export type Block = BlockMetadata & Operation; diff --git a/extensions/src/common/types/legacy.ts b/extensions/src/common/types/legacy.ts index fb36aba20..05758a686 100644 --- a/extensions/src/common/types/legacy.ts +++ b/extensions/src/common/types/legacy.ts @@ -1,5 +1,6 @@ import { ArgumentType, BlockType } from "./enums"; import { ValueOf } from "./utils"; +import { Config } from "." // Type definitions for scratch-vm (extension environment) 3.0 // Project: https://github.com/LLK/scratch-vm#readme @@ -131,6 +132,8 @@ export interface ExtensionBlockMetadata { /** Map of argument placeholder to metadata about each arg. */ arguments?: Record | undefined; + + versions?: Config; } /** All the metadata needed to register an argument for an extension block. */