add test infra + add browser tests to CI (#852)

* add test infra

* improve test setup and utils

* finish a test

* add browser tests to ci

* fix ci

* fix ci

* fix ci

* fix ci

* debug ci

* debug ci

* debug ci

* debug ci

* debug ci

* debug ci

* debug ci

* fix ci

* update lockfile

* fix formatting

* install browser when not cached

* bust cache

* debug test in ci

* fix button label

* generate screenshots for failed tests

* generate screenshots for failed tests

* generate screenshots for failed tests

* fix tests

* clean uip debug logs

* add setuip + teardown to functional tests

* remove build from static checks
This commit is contained in:
pngwn 2022-03-23 15:19:12 +00:00 committed by GitHub
parent 398f5560d1
commit 26d2c190fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 2375 additions and 362 deletions

View File

@ -2,8 +2,8 @@ name: gradio-ui
on:
push:
branches:
- "master"
branches:
- "main"
paths:
- "ui/**"
pull_request:
@ -12,23 +12,30 @@ on:
defaults:
run:
working-directory: ./ui
working-directory: ui
env:
CI: true
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
concurrency:
group: deploy-${{ github.ref }}-${{ github.event_name == 'push' || github.event.inputs.fire != null }}
cancel-in-progress: true
jobs:
check:
quick-checks:
name: static checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: npm i -g pnpm@6
- uses: actions/setup-node@v2
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.2.1
with:
version: 6
- uses: actions/setup-node@v3
with:
node-version: 16
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- name: install dependencies
run: pnpm i --frozen-lockfile
- name: formatting check
@ -38,5 +45,32 @@ jobs:
continue-on-error: true
- name: unit tests
run: pnpm test:run
- name: build
run: pnpm build
functional-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.2.1
with:
version: 6
- uses: actions/setup-node@v3
with:
node-version: 16
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- name: Cache browsers
id: browser_cache
uses: actions/cache@main
with:
path: "~/.cache/ms-playwright"
key: chromium-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
- run: pnpm install --frozen-lockfile
- run: pnpx playwright install chromium
- run: pnpm build
- run: pnpm test:browser
- name: Upload failed tests screenshots
if: failure()
uses: actions/upload-artifact@v3
with:
retention-days: 3
name: test-failure-${{ github.run_id }}
path: ui/packages/app/test-results

View File

@ -0,0 +1,126 @@
{
"mode": "blocks",
"components": [
{
"id": 1,
"type": "markdown",
"props": {
"value": "<pre><code># Detect Disease From Scan\nWith this model you can lorem ipsum\n- ipsum 1\n- ipsum 2\n</code></pre>\n",
"name": "markdown",
"label": null,
"css": {}
}
},
{
"id": 2,
"type": "checkboxgroup",
"props": {
"choices": ["Covid", "Malaria", "Lung Cancer"],
"default": [],
"name": "checkboxgroup",
"label": "Disease to Scan For",
"css": {}
}
},
{ "id": 3, "type": "tabs", "props": null },
{ "id": 4, "type": "tabitem", "props": { "label": "X-ray" } },
{ "id": 5, "type": "row", "props": { "type": "row" } },
{
"id": 6,
"type": "image",
"props": {
"image_mode": "RGB",
"shape": null,
"source": "upload",
"tool": "editor",
"name": "image",
"label": null,
"css": {}
}
},
{
"id": 7,
"type": "json",
"props": { "name": "json", "label": null, "css": {} }
},
{
"id": 8,
"type": "button",
"props": {
"value": "Run",
"name": "button",
"label": null,
"css": { "background-color": "red", "--hover-color": "orange" }
}
},
{ "id": 9, "type": "tabitem", "props": { "label": "CT Scan" } },
{ "id": 10, "type": "row", "props": { "type": "row" } },
{
"id": 11,
"type": "image",
"props": {
"image_mode": "RGB",
"shape": null,
"source": "upload",
"tool": "editor",
"name": "image",
"label": null,
"css": {}
}
},
{
"id": 12,
"type": "json",
"props": { "name": "json", "label": null, "css": {} }
},
{
"id": 13,
"type": "button",
"props": { "value": "Run", "name": "button", "label": null, "css": {} }
},
{
"id": 14,
"type": "textbox",
"props": {
"lines": 1,
"placeholder": null,
"default": "",
"name": "textbox",
"label": null,
"css": {}
}
}
],
"theme": "default",
"layout": {
"id": 0,
"children": [
{ "id": 1 },
{ "id": 2 },
{
"id": 3,
"children": [
{
"id": 4,
"children": [
{ "id": 5, "children": [{ "id": 6 }, { "id": 7 }] },
{ "id": 8 }
]
},
{
"id": 9,
"children": [
{ "id": 10, "children": [{ "id": 11 }, { "id": 12 }] },
{ "id": 13 }
]
}
]
},
{ "id": 14 }
]
},
"dependencies": [
{ "targets": [8], "trigger": "click", "inputs": [2, 6], "outputs": [7] },
{ "targets": [13], "trigger": "click", "inputs": [2, 11], "outputs": [12] }
]
}

View File

@ -1,3 +1,4 @@
from textwrap import indent
import gradio as gr
import random
@ -38,4 +39,5 @@ with xray_blocks:
overall_probability = gr.components.Textbox()
print(xray_blocks.get_config_file())
xray_blocks.launch()

View File

@ -45,9 +45,9 @@
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.3.1/iframeResizer.contentWindow.min.js"></script>
<title>Gradio</title>
<script type="module" crossorigin src="./assets/index.b8adfcc9.js"></script>
<link rel="modulepreload" href="./assets/vendor.cb9b505c.js">
<link rel="stylesheet" href="./assets/index.7e32d9ef.css">
<script type="module" crossorigin src="./assets/index.cdad49d2.js"></script>
<link rel="modulepreload" href="./assets/vendor.90013e04.js">
<link rel="stylesheet" href="./assets/index.778d40cb.css">
</head>
<body style="height: 100%; margin: 0; padding: 0">

View File

@ -1,18 +0,0 @@
{
"interface": {
"submit": "Submit",
"clear": "Clear",
"interpret": "Interpret",
"flag": "Flag",
"examples": "Examples",
"drop_image": "Drop Image Here",
"drop_video": "Drop Video Here",
"drop_audio": "Drop Audio Here",
"drop_file": "Drop File Here",
"drop_csv": "Drop CSV Here",
"or": "or",
"click_to_upload": "Click to Upload",
"view_api": "view the api",
"built_with_Gradio": "built with gradio"
}
}

View File

@ -1,18 +0,0 @@
{
"interface": {
"submit": "Enviar",
"clear": "Limpiar",
"interpret": "Interpretar",
"flag": "Avisar",
"examples": "Ejemplos",
"drop_image": "Coloque la imagen aquí",
"drop_video": "Coloque el video aquí",
"drop_audio": "Coloque el audio aquí",
"drop_file": "Coloque el archivo aquí",
"drop_csv": "Coloque el CSV aquí",
"or": "o",
"click_to_upload": "Haga click para cargar",
"view_api": "Ver la API",
"built_with_Gradio": "Construido con Gradio"
}
}

2
ui/.gitignore vendored
View File

@ -1,4 +1,4 @@
node_modules
public/build/
test-results

View File

@ -10,23 +10,38 @@
"format:write": "prettier --write --plugin-search-dir=. .",
"ts:check": "svelte-check --tsconfig tsconfig.json",
"test": "vitest dev",
"test:run": "vitest run"
"test:run": "vitest run",
"test:browser": "pnpm test:browser --filter @gradio/app",
"test:browser:full": "run-s build test:browser",
"test:browser:debug": "pnpm test:browser:debug --filter @gradio/app"
},
"type": "module",
"author": "",
"license": "ISC",
"private": true,
"dependencies": {
"autoprefixer": "^9.8.8",
"@gradio/tootils": "workspace:^0.0.1",
"@playwright/test": "^1.20.0",
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.36",
"@testing-library/dom": "^8.11.3",
"@testing-library/svelte": "^3.1.0",
"autoprefixer": "^10.4.4",
"happy-dom": "^2.49.0",
"npm-run-all": "^4.1.5",
"polka": "^1.0.0-next.22",
"postcss": "^8.4.5",
"postcss-nested": "^5.0.6",
"prettier": "^2.5.1",
"prettier-plugin-svelte": "^2.6.0",
"sirv": "^2.0.2",
"sirv-cli": "^2.0.2",
"svelte": "^3.46.3",
"svelte-check": "^2.4.1",
"svelte-i18n": "^3.3.13",
"svelte-preprocess": "^4.10.1",
"tailwindcss": "^3.0.23",
"vitest": "^0.3.2"
"tinyspy": "^0.3.0",
"vite": "^2.7.13",
"vitest": "^0.7.4"
}
}

View File

@ -5,11 +5,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.36",
"vite": "^2.7.13"
"preview": "vite preview",
"test:browser": "pnpx playwright test test/ --config=../../playwright.config.js",
"test:browser:debug": "pnpx playwright test test/ --debug"
},
"dependencies": {
"@gradio/audio": "workspace:^0.0.1",
@ -30,7 +28,6 @@
"@gradio/theme": "workspace:^0.0.1",
"@gradio/upload": "workspace:^0.0.1",
"@gradio/video": "workspace:^0.0.1",
"autoprefixer": "^9.8.8",
"mime-types": "^2.1.34",
"svelte-i18n": "^3.3.13"
}

View File

@ -67,7 +67,7 @@
const c = await component_map[name]();
res({ name, component: c });
} catch (e) {
console.log(name)
console.log(name);
rej(e);
}
});

View File

@ -2,7 +2,6 @@
import { Button } from "@gradio/button";
export let value: string;
export let label: string;
export let style: string | null;
export let variant: "primary" | "secondary" = "primary";
</script>

View File

@ -0,0 +1,23 @@
import { test, describe, assert, afterAll } from "vitest";
import { spy } from "tinyspy";
import { cleanup, fireEvent, render } from "@gradio/tootils";
import Button from "./Button.svelte";
describe("Hello.svelte", () => {
afterAll(() => cleanup());
const { container, component } = render(Button, { value: "Click Me" });
test("renders label text", () => {
assert.equal(container.innerText, "Click Me");
});
test("triggers callback when clicked", async () => {
const mock = spy();
component.$on("click", mock);
fireEvent.click(container.querySelector("button")!);
assert.isTrue(mock.called);
});
});

View File

@ -0,0 +1,15 @@
<script>
import Carousel from "./Carousel.svelte";
import CarouselItem from "../CarouselItem/CarouselItem.svelte";
import api_logo from "../../../public/static/img/api-logo.svg";
</script>
<Carousel on:change>
<CarouselItem>
<h1>Item 1</h1>
</CarouselItem>
<CarouselItem>
<img src={api_logo} alt="" />
</CarouselItem>
</Carousel>

View File

@ -0,0 +1,52 @@
import { test, describe, assert, afterEach } from "vitest";
import { spy } from "tinyspy";
import { cleanup, fireEvent, render } from "@gradio/tootils";
import Carousel from "./Carousel.test.svelte";
describe("Carousel + CarouselItem", () => {
afterEach(() => cleanup());
test("renders first carousel item is rendered by default", () => {
const { container } = render(Carousel);
const item = container.querySelector(".component")!;
assert.equal(item.children[0].tagName, "H1");
assert.equal(item.children[0].innerHTML, "Item 1");
});
test("clicking next shows the second component", async () => {
const { container } = render(Carousel);
const [, next] = Array.from(container.querySelectorAll("button"));
await fireEvent.click(next);
const item = container.querySelector(".component")!;
assert.equal(item.children[0].tagName, "IMG");
});
test("clicking previous from index 0 shows the last component", async () => {
const { container } = render(Carousel);
const [previous] = Array.from(container.querySelectorAll("button"));
await fireEvent.click(previous);
const item = container.querySelector(".component")!;
assert.equal(item.children[0].tagName, "IMG");
});
test("change callback is triggered when", async () => {
const { container, component } = render(Carousel);
const [previous, next] = Array.from(container.querySelectorAll("button"));
const mock = spy();
component.$on("change", mock);
fireEvent.click(next);
fireEvent.click(previous);
assert.isTrue(mock.called);
assert.equal(mock.callCount, 2);
});
});

View File

@ -0,0 +1,48 @@
import { test, describe, assert, afterEach } from "vitest";
import { cleanup, render, get_text } from "@gradio/tootils";
import Chatbot from "./Chatbot.svelte";
describe("Chatbot", () => {
afterEach(() => cleanup());
test("renders bot and user messages", () => {
const { getAllByTestId } = render(Chatbot, {
value: [["bot message one", "user message one"]]
});
const bot = getAllByTestId("bot")[0];
const user = getAllByTestId("user")[0];
assert.equal(get_text(bot), "bot message one");
assert.equal(get_text(user), "user message one");
});
test("renders additional message as they are passed", async () => {
const { component, getAllByTestId } = render(Chatbot, {
value: [["bot message one", "user message one"]]
});
const bot = getAllByTestId("bot");
const user = getAllByTestId("user");
assert.equal(bot.length, 1);
assert.equal(user.length, 1);
await component.$set({
value: [
["bot message one", "user message one"],
["bot message two", "user message two"]
]
});
const bot_2 = getAllByTestId("bot");
const user_2 = getAllByTestId("user");
assert.equal(bot_2.length, 2);
assert.equal(user_2.length, 2);
assert.equal(get_text(bot_2[1]), "bot message two");
assert.equal(get_text(user_2[1]), "user message two");
});
});

View File

@ -13,5 +13,4 @@
<th>{header}</th>
{/each}
</thead>
</table>

View File

@ -121,7 +121,7 @@ window.launchGradioFromSpaces = async (space: string, target: string) => {
};
async function get_config() {
if (BUILD_MODE === "dev") {
if (BUILD_MODE === "dev" || location.origin === "http://localhost:3000") {
let config = await fetch(BACKEND_URL + "config");
config = await config.json();
return config;

View File

@ -0,0 +1,78 @@
import { test, expect, Page } from "@playwright/test";
function mock_demo(page: Page, demo: string) {
return page.route("http://localhost:7860/config", (route) => {
return route.fulfill({
headers: {
"Access-Control-Allow-Origin": "*"
},
path: `../../../demo/${demo}/config.json`
});
});
}
function mock_api(page: Page, body: Array<unknown>) {
return page.route("http://localhost:7860/api/predict/", (route) => {
const id = JSON.parse(route.request().postData()!).fn_index;
return route.fulfill({
headers: {
"Access-Control-Allow-Origin": "*"
},
body: JSON.stringify({
data: body[id]
})
});
});
}
test("renders the correct elements", async ({ page }) => {
await mock_demo(page, "xray_blocks");
await page.goto("http://localhost:3000");
const description = await page.locator(".output-markdown");
await expect(description).toContainText("Detect Disease From Scan");
const checkboxes = await page.locator(".input-checkbox-group");
await expect(checkboxes).toContainText("Covid Malaria Lung Cancer");
const tabs = await page.locator("button", { hasText: /X-ray|CT Scan/ });
await expect(tabs).toHaveCount(2);
});
test("can run an api request and display the data", async ({ page }) => {
await mock_demo(page, "xray_blocks");
await mock_api(page, [
[
{
Covid: 0.75,
"Lung Cancer": 0.25
}
],
[
{
Covid: 0.75,
"Lung Cancer": 0.25
}
]
]);
await page.goto("http://localhost:3000");
await page.locator('button:has-text("Covid")').click();
await page.locator('button:has-text("Lung Cancer")').click();
const run_button = await page.locator("button", { hasText: /Run/ });
await Promise.all([
run_button.click(),
page.waitForResponse("http://localhost:7860/api/predict/")
]);
const json = await page.locator(".output-json");
await expect(await json.innerText()).toContain(
`{
Covid: 0.75,
Lung Cancer: 0.25
}`
);
});

View File

@ -4,7 +4,7 @@ import sveltePreprocess from "svelte-preprocess";
// this is dupe config, gonna try fix this
import tailwind from "tailwindcss";
import nested from "tailwindcss/nesting";
import nested from "tailwindcss/nesting/index.js";
//@ts-ignore
export default defineConfig(({ mode }) => {
@ -17,9 +17,7 @@ export default defineConfig(({ mode }) => {
},
define: {
BUILD_MODE: production ? JSON.stringify("prod") : JSON.stringify("dev"),
BACKEND_URL: production
? JSON.stringify("")
: JSON.stringify("http://localhost:7860/")
BACKEND_URL: JSON.stringify("http://localhost:7860/")
},
css: {
postcss: {
@ -28,10 +26,15 @@ export default defineConfig(({ mode }) => {
},
plugins: [
svelte({
hot: !process.env.VITEST,
preprocess: sveltePreprocess({
postcss: { plugins: [tailwind, nested] }
})
})
]
],
test: {
environment: "happy-dom",
include: ["**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"]
}
};
});

View File

@ -15,4 +15,4 @@
button {
@apply hover:bg-gray-200;
}
</style>
</style>

View File

@ -24,10 +24,14 @@
>
<div class="flex flex-col items-end space-y-4 p-3">
{#each value as message}
<div class="px-3 py-2 rounded-2xl bg-yellow-500 text-white ml-7">
<div
data-testid="bot"
class="px-3 py-2 rounded-2xl bg-yellow-500 text-white ml-7"
>
{message[0]}
</div>
<div
data-testid="user"
class="px-3 py-2 rounded-2xl place-self-start bg-gray-300 dark:bg-gray-850 dark:text-gray-200 mr-7"
>
{message[1]}

View File

@ -2,7 +2,6 @@
import { createEventDispatcher } from "svelte";
import "./typography.css";
export let value: string;
export let theme: string = "default";

View File

@ -0,0 +1,11 @@
# `@gradio/button`
```html
<script>
import { Button } from "@gradio/button";
</script>
<button type="primary|secondary" href="string" on:click="{e.detail === href}">
content
</button>
```

View File

@ -0,0 +1,10 @@
{
"name": "@gradio/tootils",
"version": "0.0.1",
"description": "Internal test utilities",
"type": "module",
"main": "src/index.ts",
"author": "",
"license": "ISC",
"private": true
}

View File

@ -0,0 +1,5 @@
export function get_text<T extends HTMLElement>(el: T) {
return el.innerText.trim();
}
export * from "./render";

View File

@ -0,0 +1,65 @@
import { getQueriesForElement, prettyDOM } from "@testing-library/dom";
import type { SvelteComponentTyped } from "svelte";
const containerCache = new Map();
const componentCache = new Set();
type Component<T extends SvelteComponentTyped, Props> = new (args: {
target: any;
props?: Props;
}) => T;
function render<Props, T extends SvelteComponentTyped<Props>>(
Component: Component<T, Props> | { default: Component<T, Props> },
props?: Props
) {
const container = document.body;
const target = container.appendChild(document.createElement("div"));
const ComponentConstructor: Component<T, Props> =
//@ts-ignore
Component.default || Component;
const component = new ComponentConstructor({
target,
props
});
containerCache.set(container, { target, component });
componentCache.add(component);
component.$$.on_destroy.push(() => {
componentCache.delete(component);
});
return {
container,
component,
debug: (el = container) => console.log(prettyDOM(el)),
unmount: () => {
if (componentCache.has(component)) component.$destroy();
},
...getQueriesForElement(container)
};
}
const cleanupAtContainer = (container: HTMLElement) => {
const { target, component } = containerCache.get(container);
if (componentCache.has(component)) component.$destroy();
if (target.parentNode === document.body) {
document.body.removeChild(target);
}
containerCache.delete(container);
};
const cleanup = () => {
Array.from(containerCache.keys()).forEach(cleanupAtContainer);
};
export * from "@testing-library/dom";
export { render, cleanup };
export { fireEvent } from "@testing-library/svelte";

View File

@ -7,8 +7,12 @@
];
setTimeout(() => {
messages = [...messages, ["message five", "message six"]];
messages = [...messages, ["message five", ""]];
}, 1000);
setTimeout(() => {
messages = [...messages, ["message five", "message six"]];
}, 2000);
</script>
<ChatBot value={messages} />

21
ui/playwright-setup.js Normal file
View File

@ -0,0 +1,21 @@
import polka from "polka";
import sirv from "sirv";
import path from "path";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const template = path.join(__dirname, "..", "gradio", "templates", "frontend");
export default async function global_setup() {
const serve = sirv(template);
const app = polka()
.use(serve)
.listen("3000", () => {
console.log(`> Running on localhost: 3000`);
});
return () => {
app.server.close();
};
}

7
ui/playwright.config.js Normal file
View File

@ -0,0 +1,7 @@
export default {
use: {
screenshot: "only-on-failure",
trace: "retain-on-failure"
},
globalSetup: "./playwright-setup.js"
};

2112
ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff