diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..f3c0ced --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [abi] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..386e7e3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,21 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Screenshots of backend AND frontend terminal logs** +If applicable, add screenshots to help explain your problem. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 0000000..48d5f81 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,10 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: '' +assignees: '' + +--- + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/README.md b/README.md index 4b681b1..7a7531a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ We also just added experimental support for taking a video/screen recording of a [Follow me on Twitter for updates](https://twitter.com/_abi_). +## Sponsors + + + + ## ๐Ÿš€ Try It Out without no install [Try it live on the hosted version (paid)](https://screenshottocode.com). diff --git a/frontend/.gitignore b/frontend/.gitignore index 17ceca3..a0d3702 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -25,3 +25,6 @@ dist-ssr # Env files .env* + +# Test files +src/tests/results/ diff --git a/frontend/jest.config.js b/frontend/jest.config.js new file mode 100644 index 0000000..310efb5 --- /dev/null +++ b/frontend/jest.config.js @@ -0,0 +1,9 @@ +export default { + preset: "ts-jest", + testEnvironment: "node", + setupFiles: ["/src/setupTests.ts"], + transform: { + "^.+\\.tsx?$": "ts-jest", + }, + testTimeout: 30000, +}; diff --git a/frontend/package.json b/frontend/package.json index b8f2737..3bde6c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,7 @@ "build-hosted": "tsc && vite build --mode prod", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", - "test": "vitest" + "test": "jest" }, "dependencies": { "@clerk/clerk-react": "^4.29.0", @@ -57,18 +57,24 @@ "zustand": "^4.4.7" }, "devDependencies": { + "@types/jest": "^29.5.12", "@types/node": "^20.9.0", + "@types/puppeteer": "^7.0.4", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "@vitejs/plugin-react": "^4.0.3", "autoprefixer": "^10.4.16", + "dotenv": "^16.4.5", "eslint": "^8.45.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", + "jest": "^29.7.0", "postcss": "^8.4.31", + "puppeteer": "^22.6.4", "tailwindcss": "^3.3.5", + "ts-jest": "^29.1.2", "typescript": "^5.0.2", "vite": "^4.4.5", "vite-plugin-html": "^3.2.0", diff --git a/frontend/src/.env.jest.example b/frontend/src/.env.jest.example new file mode 100644 index 0000000..59bc657 --- /dev/null +++ b/frontend/src/.env.jest.example @@ -0,0 +1,2 @@ +TEST_SCREENSHOTONE_API_KEY= +TEST_ROOT_PATH= diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c00df36..1ec6abe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -516,7 +516,7 @@ function App({ navbarComponent }: Props) { @@ -524,7 +524,7 @@ function App({ navbarComponent }: Props) {
@@ -635,7 +635,7 @@ function App({ navbarComponent }: Props) { diff --git a/frontend/src/components/ImageUpload.tsx b/frontend/src/components/ImageUpload.tsx index c598ee0..43be361 100644 --- a/frontend/src/components/ImageUpload.tsx +++ b/frontend/src/components/ImageUpload.tsx @@ -179,7 +179,7 @@ function ImageUpload({ setReferenceImages }: Props) { {screenRecorderState === ScreenRecorderState.INITIAL && ( /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
- +

Drag & drop a screenshot here,
or click to upload diff --git a/frontend/src/components/ImportCodeSection.tsx b/frontend/src/components/ImportCodeSection.tsx index b320a97..c31e753 100644 --- a/frontend/src/components/ImportCodeSection.tsx +++ b/frontend/src/components/ImportCodeSection.tsx @@ -38,7 +38,9 @@ function ImportCodeSection({ importFromCode }: Props) { return (

- + @@ -62,7 +64,7 @@ function ImportCodeSection({ importFromCode }: Props) { /> - diff --git a/frontend/src/components/UrlInputSection.tsx b/frontend/src/components/UrlInputSection.tsx index 2c66a09..b5cfe22 100644 --- a/frontend/src/components/UrlInputSection.tsx +++ b/frontend/src/components/UrlInputSection.tsx @@ -74,7 +74,7 @@ export function UrlInputSection({ doCreate, screenshotOneApiKey }: Props) { diff --git a/frontend/src/components/history/utils.test.ts b/frontend/src/components/history/utils.test.ts index 330b8e5..e321bdc 100644 --- a/frontend/src/components/history/utils.test.ts +++ b/frontend/src/components/history/utils.test.ts @@ -1,4 +1,3 @@ -import { expect, test } from "vitest"; import { extractHistoryTree, renderHistory } from "./utils"; import type { History } from "./history_types"; @@ -84,147 +83,149 @@ const basicBadHistory: History = [ }, ]; -test("should correctly extract the history tree", () => { - expect(extractHistoryTree(basicLinearHistory, 2)).toEqual([ - "1. create", - "use better icons", - "2. edit with better icons", - "make text red", - "3. edit with better icons and red text", - ]); +describe("History Utils", () => { + test("should correctly extract the history tree", () => { + expect(extractHistoryTree(basicLinearHistory, 2)).toEqual([ + "1. create", + "use better icons", + "2. edit with better icons", + "make text red", + "3. edit with better icons and red text", + ]); - expect(extractHistoryTree(basicLinearHistory, 0)).toEqual([ - "1. create", - ]); + expect(extractHistoryTree(basicLinearHistory, 0)).toEqual([ + "1. create", + ]); - // Test branching - expect(extractHistoryTree(basicBranchingHistory, 3)).toEqual([ - "1. create", - "use better icons", - "2. edit with better icons", - "make text green", - "4. edit with better icons and green text", - ]); + // Test branching + expect(extractHistoryTree(basicBranchingHistory, 3)).toEqual([ + "1. create", + "use better icons", + "2. edit with better icons", + "make text green", + "4. edit with better icons and green text", + ]); - expect(extractHistoryTree(longerBranchingHistory, 4)).toEqual([ - "1. create", - "use better icons", - "2. edit with better icons", - "make text green", - "4. edit with better icons and green text", - "make text bold", - "5. edit with better icons and green, bold text", - ]); + expect(extractHistoryTree(longerBranchingHistory, 4)).toEqual([ + "1. create", + "use better icons", + "2. edit with better icons", + "make text green", + "4. edit with better icons and green text", + "make text bold", + "5. edit with better icons and green, bold text", + ]); - expect(extractHistoryTree(longerBranchingHistory, 2)).toEqual([ - "1. create", - "use better icons", - "2. edit with better icons", - "make text red", - "3. edit with better icons and red text", - ]); + expect(extractHistoryTree(longerBranchingHistory, 2)).toEqual([ + "1. create", + "use better icons", + "2. edit with better icons", + "make text red", + "3. edit with better icons and red text", + ]); - // Errors + // Errors - // Bad index - expect(() => extractHistoryTree(basicLinearHistory, 100)).toThrow(); - expect(() => extractHistoryTree(basicLinearHistory, -2)).toThrow(); + // Bad index + expect(() => extractHistoryTree(basicLinearHistory, 100)).toThrow(); + expect(() => extractHistoryTree(basicLinearHistory, -2)).toThrow(); - // Bad tree - expect(() => extractHistoryTree(basicBadHistory, 1)).toThrow(); -}); - -test("should correctly render the history tree", () => { - expect(renderHistory(basicLinearHistory, 2)).toEqual([ - { - isActive: false, - parentVersion: null, - summary: "Create", - type: "Create", - }, - { - isActive: false, - parentVersion: null, - summary: "use better icons", - type: "Edit", - }, - { - isActive: true, - parentVersion: null, - summary: "make text red", - type: "Edit", - }, - ]); - - // Current version is the first version - expect(renderHistory(basicLinearHistory, 0)).toEqual([ - { - isActive: true, - parentVersion: null, - summary: "Create", - type: "Create", - }, - { - isActive: false, - parentVersion: null, - summary: "use better icons", - type: "Edit", - }, - { - isActive: false, - parentVersion: null, - summary: "make text red", - type: "Edit", - }, - ]); - - // Render a history with code - expect(renderHistory(basicLinearHistoryWithCode, 0)).toEqual([ - { - isActive: true, - parentVersion: null, - summary: "Imported from code", - type: "Imported from code", - }, - { - isActive: false, - parentVersion: null, - summary: "use better icons", - type: "Edit", - }, - { - isActive: false, - parentVersion: null, - summary: "make text red", - type: "Edit", - }, - ]); - - // Render a non-linear history - expect(renderHistory(basicBranchingHistory, 3)).toEqual([ - { - isActive: false, - parentVersion: null, - summary: "Create", - type: "Create", - }, - { - isActive: false, - parentVersion: null, - summary: "use better icons", - type: "Edit", - }, - { - isActive: false, - parentVersion: null, - summary: "make text red", - type: "Edit", - }, - { - isActive: true, - parentVersion: "v2", - summary: "make text green", - type: "Edit", - }, - ]); + // Bad tree + expect(() => extractHistoryTree(basicBadHistory, 1)).toThrow(); + }); + + test("should correctly render the history tree", () => { + expect(renderHistory(basicLinearHistory, 2)).toEqual([ + { + isActive: false, + parentVersion: null, + summary: "Create", + type: "Create", + }, + { + isActive: false, + parentVersion: null, + summary: "use better icons", + type: "Edit", + }, + { + isActive: true, + parentVersion: null, + summary: "make text red", + type: "Edit", + }, + ]); + + // Current version is the first version + expect(renderHistory(basicLinearHistory, 0)).toEqual([ + { + isActive: true, + parentVersion: null, + summary: "Create", + type: "Create", + }, + { + isActive: false, + parentVersion: null, + summary: "use better icons", + type: "Edit", + }, + { + isActive: false, + parentVersion: null, + summary: "make text red", + type: "Edit", + }, + ]); + + // Render a history with code + expect(renderHistory(basicLinearHistoryWithCode, 0)).toEqual([ + { + isActive: true, + parentVersion: null, + summary: "Imported from code", + type: "Imported from code", + }, + { + isActive: false, + parentVersion: null, + summary: "use better icons", + type: "Edit", + }, + { + isActive: false, + parentVersion: null, + summary: "make text red", + type: "Edit", + }, + ]); + + // Render a non-linear history + expect(renderHistory(basicBranchingHistory, 3)).toEqual([ + { + isActive: false, + parentVersion: null, + summary: "Create", + type: "Create", + }, + { + isActive: false, + parentVersion: null, + summary: "use better icons", + type: "Edit", + }, + { + isActive: false, + parentVersion: null, + summary: "make text red", + type: "Edit", + }, + { + isActive: true, + parentVersion: "v2", + summary: "make text green", + type: "Edit", + }, + ]); + }); }); diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts new file mode 100644 index 0000000..b1ea596 --- /dev/null +++ b/frontend/src/setupTests.ts @@ -0,0 +1,3 @@ +// So jest test runner can read env vars from .env file +import { config } from "dotenv"; +config({ path: ".env.jest" }); diff --git a/frontend/src/tests/fixtures/simple_button.png b/frontend/src/tests/fixtures/simple_button.png new file mode 100644 index 0000000..e4f776b Binary files /dev/null and b/frontend/src/tests/fixtures/simple_button.png differ diff --git a/frontend/src/tests/fixtures/simple_ui_with_image.png b/frontend/src/tests/fixtures/simple_ui_with_image.png new file mode 100644 index 0000000..7533f85 Binary files /dev/null and b/frontend/src/tests/fixtures/simple_ui_with_image.png differ diff --git a/frontend/src/tests/qa.test.ts b/frontend/src/tests/qa.test.ts new file mode 100644 index 0000000..b5274d1 --- /dev/null +++ b/frontend/src/tests/qa.test.ts @@ -0,0 +1,274 @@ +import puppeteer, { Browser, Page, ElementHandle } from "puppeteer"; +import { Stack } from "../lib/stacks"; +import { CodeGenerationModel } from "../lib/models"; + +const TESTS_ROOT_PATH = process.env.TEST_ROOT_PATH; + +// Fixtures +const FIXTURES_PATH = `${TESTS_ROOT_PATH}/fixtures`; +const SIMPLE_SCREENSHOT = FIXTURES_PATH + "/simple_button.png"; +const SCREENSHOT_WITH_IMAGES = `${FIXTURES_PATH}/simple_ui_with_image.png`; + +// Results +const RESULTS_DIR = `${TESTS_ROOT_PATH}/results`; + +describe("e2e tests", () => { + let browser: Browser; + let page: Page; + + const DEBUG = false; + const IS_HEADLESS = true; + + const stacks = Object.values(Stack).slice(0, DEBUG ? 1 : undefined); + const models = Object.values(CodeGenerationModel).slice( + 0, + DEBUG ? 1 : undefined + ); + + beforeAll(async () => { + browser = await puppeteer.launch({ headless: IS_HEADLESS }); + page = await browser.newPage(); + await page.goto("http://localhost:5173/"); + + // Set screen size + await page.setViewport({ width: 1080, height: 1024 }); + + // TODO: Does this need to be moved? + // const client = await page.createCDPSession(); + // Set download behavior path + // await client.send("Page.setDownloadBehavior", { + // behavior: "allow", + // downloadPath: DOWNLOAD_PATH, + // }); + }); + + afterAll(async () => { + await browser.close(); + }); + + // Create tests + models.forEach((model) => { + stacks.forEach((stack) => { + it( + `Create for : ${model} & ${stack}`, + async () => { + const app = new App( + page, + stack, + model, + `create_screenshot_${model}_${stack}` + ); + await app.init(); + // Generate from screenshot + await app.uploadImage(SCREENSHOT_WITH_IMAGES); + }, + 60 * 1000 + ); + + it( + `Create from URL for : ${model} & ${stack}`, + async () => { + const app = new App( + page, + stack, + model, + `create_url_${model}_${stack}` + ); + await app.init(); + // Generate from screenshot + await app.generateFromUrl("https://a.picoapps.xyz/design-fear"); + }, + 60 * 1000 + ); + }); + }); + + // Update tests - for every model (doesnโ€™t need to be repeated for each stack - fix to HTML Tailwind only) + models.forEach((model) => { + ["html_tailwind"].forEach((stack) => { + it( + `update: ${model}`, + async () => { + const app = new App(page, stack, model, `update_${model}_${stack}`); + await app.init(); + + // Generate from screenshot + await app.uploadImage(SIMPLE_SCREENSHOT); + // Regenerate works for v1 + await app.regenerate(); + // Make an update + await app.edit("make the button background blue", "v2"); + // Make another update + await app.edit("make the text italic", "v3"); + // Branch off v2 and make an update + await app.clickVersion("v2"); + await app.edit("make the text yellow", "v4"); + }, + 90 * 1000 + ); + }); + }); + + // Start from code tests - for every model + models.forEach((model) => { + ["html_tailwind"].forEach((stack) => { + it.skip( + `Start from code: ${model}`, + async () => { + const app = new App( + page, + stack, + model, + `start_from_code_${model}_${stack}` + ); + await app.init(); + + await app.importFromCode(); + + // Regenerate works for v1 + // await app.regenerate(); + // // Make an update + // await app.edit("make the header blue", "v2"); + // // Make another update + // await app.edit("make all text italic", "v3"); + // // Branch off v2 and make an update + // await app.clickVersion("v2"); + // await app.edit("make all text red", "v4"); + }, + 90 * 1000 + ); + }); + }); +}); + +class App { + private screenshotPathPrefix: string; + private page: Page; + private stack: string; + private model: string; + + constructor(page: Page, stack: string, model: string, testId: string) { + this.page = page; + this.stack = stack; + this.model = model; + this.screenshotPathPrefix = `${RESULTS_DIR}/${testId}`; + } + + async init() { + await this.setupLocalStorage(); + } + + async setupLocalStorage() { + const setting = { + openAiApiKey: null, + openAiBaseURL: null, + screenshotOneApiKey: process.env.TEST_SCREENSHOTONE_API_KEY, + isImageGenerationEnabled: true, + editorTheme: "cobalt", + generatedCodeConfig: this.stack, + codeGenerationModel: this.model, + isTermOfServiceAccepted: false, + accessCode: null, + }; + + await this.page.evaluate((setting) => { + localStorage.setItem("setting", JSON.stringify(setting)); + }, setting); + + // Reload the page to apply the local storage + await this.page.reload(); + } + + async _screenshot(step: string) { + await this.page.screenshot({ + path: `${this.screenshotPathPrefix}_${step}.png`, + }); + } + + async _waitUntilVersionIsReady(version: string) { + await this.page.waitForNetworkIdle(); + await this.page.waitForFunction( + (version) => document.body.innerText.includes(version), + { + timeout: 30000, + }, + version + ); + // Wait for 3s so that the HTML and JS has time to render before screenshotting + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + + async generateFromUrl(url: string) { + // Type in the URL + await this.page.type('input[placeholder="Enter URL"]', url); + await this._screenshot("typed_url"); + + // Click the capture button and wait for the code to be generated + await this.page.click("button.capture-btn"); + await this._waitUntilVersionIsReady("v1"); + await this._screenshot("url_result"); + } + + // Uploads a screenshot and generates the image + async uploadImage(screenshotPath: string) { + // Upload file + const fileInput = (await this.page.$( + ".file-input" + )) as ElementHandle; + if (!fileInput) { + throw new Error("File input element not found"); + } + await fileInput.uploadFile(screenshotPath); + await this._screenshot("image_uploaded"); + + // Click the generate button and wait for the code to be generated + await this._waitUntilVersionIsReady("v1"); + await this._screenshot("image_results"); + } + + // Makes a text edit and waits for a new version + async edit(edit: string, version: string) { + // Type in the edit + await this.page.type( + 'textarea[placeholder="Tell the AI what to change..."]', + edit + ); + await this._screenshot(`typed_${version}`); + + // Click the update button and wait for the code to be generated + await this.page.click(".update-btn"); + await this._waitUntilVersionIsReady(version); + await this._screenshot(`done_${version}`); + } + + async clickVersion(version: string) { + await this.page.evaluate((version) => { + document.querySelectorAll("div").forEach((div) => { + if (div.innerText.includes(version)) { + div.click(); + } + }); + }, version); + } + + async regenerate() { + await this.page.click(".regenerate-btn"); + await this._waitUntilVersionIsReady("v1"); + await this._screenshot("regenerate_results"); + } + + // Work in progress + async importFromCode() { + await this.page.click(".import-from-code-btn"); + + await this.page.type("textarea", "hello world"); + + await this.page.select("#output-settings-js", "HTML + Tailwind"); + + await this._screenshot("typed_code"); + + await this.page.click(".import-btn"); + + await this._waitUntilVersionIsReady("v1"); + } +}