diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5739d2e..61d36a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,18 @@ jobs: deno fmt --check deno lint + TestJavaScript: + name: JavaScript / Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - run: deno install + - run: npx playwright install chromium + - run: deno task vitest --run + TypecheckJavaScript: name: JavaScript / Typecheck runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 3fd821e..b210285 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ dist/ .venv examples/test.mcool *.bigWig +node_modules +__screenshots__ diff --git a/deno.json b/deno.json index be08ec6..c609edd 100644 --- a/deno.json +++ b/deno.json @@ -1,5 +1,6 @@ { "lock": false, + "nodeModulesDir": "auto", "compilerOptions": { "checkJs": true, "lib": ["dom", "dom.iterable", "esnext"] @@ -11,5 +12,15 @@ }, "fmt": { "exclude": ["examples"] + }, + "tasks": { + "vitest": "deno run -A vitest" + }, + "imports": { + "@anywidget/types": "npm:@anywidget/types@^0.2.0", + "@deno/vite-plugin": "npm:@deno/vite-plugin@^1.0.6", + "@vitest/browser-playwright": "npm:@vitest/browser-playwright@^4.0.18", + "vite": "npm:vite@^7.3.1", + "vitest": "npm:vitest@^4.0.18" } } diff --git a/src/higlass/types.ts b/src/higlass/types.ts index bcb41b2..4f30c3a 100644 --- a/src/higlass/types.ts +++ b/src/higlass/types.ts @@ -82,5 +82,7 @@ export type GenomicLocation = { /** Partial types for the viewconf */ export type Viewconf = { - views: Array<{ uid: string }>; + views: Array< + { uid: string; layout?: unknown; tracks?: unknown } + >; }; diff --git a/src/higlass/widget.js b/src/higlass/widget.js index f46cf49..c00474a 100644 --- a/src/higlass/widget.js +++ b/src/higlass/widget.js @@ -1,6 +1,7 @@ import * as hglib from "https://esm.sh/higlass@1.13?deps=react@17,react-dom@17,pixi.js@6"; import { v4 } from "https://esm.sh/@lukeed/uuid@2.0.1"; +/** @import { AnyModel } from "@anywidget/types" */ /** @import { PluginDataFetcherConstructor, GenomicLocation, Viewconf, DataFetcher} from "./types.ts" */ const NAME = "jupyter"; @@ -111,7 +112,7 @@ function assert(expression, msg = "") { * ``` * * @template T - * @param {import("npm:@anywidget/types").AnyModel} model + * @param {AnyModel} model * @param {{ payload: unknown, signal?: AbortSignal }} options * @return {Promise<{ payload: T, buffers: Array }>} */ @@ -192,7 +193,7 @@ function resolveJupyterServers(viewConfig) { } /** - * @param {import("npm:@anywidget/types@0.2.0").AnyModel} model */ + * @param {AnyModel} model */ async function registerJupyterHiGlassDataFetcher(model) { if (window?.higlassDataFetchersByType?.[NAME]) { return; @@ -286,7 +287,7 @@ function addEventListenersTo(el) { */ export default { - /** @type {import("npm:@anywidget/types").Render} */ + /** @type {import("@anywidget/types").Render} */ async render({ model, el }) { await Promise.all([ requireScripts(model.get("_plugin_urls")), diff --git a/src/higlass/widget.test.ts b/src/higlass/widget.test.ts new file mode 100644 index 0000000..c7934a6 --- /dev/null +++ b/src/higlass/widget.test.ts @@ -0,0 +1,66 @@ +import { expect, onTestFinished, test, vi } from "vitest"; +import type { AnyModel, Experimental } from "@anywidget/types"; +import type { State } from "./widget.js"; + +const experimental: Experimental = { + invoke() { + throw new Error("experimental.invoke is not implemented."); + }, +}; + +test("render creates a HiGlass viewer with a simple viewconf", async () => { + const { default: widget } = await import("./widget.js"); + + const el = document.createElement("div"); + el.style.width = "800px"; + el.style.height = "400px"; + document.body.appendChild(el); + onTestFinished(() => el.remove()); + + const tilesetModel = { + on: vi.fn(), + off: vi.fn(), + send: vi.fn(), + }; + + const model: AnyModel = { + get(key) { + const state: State = { + _plugin_urls: [], + _viewconf: { + views: [{ + uid: "v", + layout: { x: 0, y: 0, w: 12, h: 6 }, + tracks: { + top: [{ type: "top-axis", uid: "t" }], + center: [], + left: [], + right: [], + bottom: [], + }, + }], + }, + _options: {}, + _tileset_client: "IPY_MODEL_fake", + location: [0, 0, 0, 0], + }; + return state[key]; + }, + set: vi.fn(), + save_changes: vi.fn(), + on: vi.fn(), + off: vi.fn(), + send: vi.fn(), + widget_manager: { + get_model: vi.fn().mockResolvedValue(tilesetModel), + }, + }; + + const cleanup = await widget.render({ model, el, experimental }); + + // resolved without throwing; container has content + expect(el.children.length).toBeGreaterThan(0); + + expect(typeof cleanup).toBe("function"); + cleanup?.(); +}); diff --git a/vite.config.mjs b/vite.config.mjs new file mode 100644 index 0000000..ececcc6 --- /dev/null +++ b/vite.config.mjs @@ -0,0 +1,23 @@ +import { defineConfig } from "vitest/config"; + +import { playwright } from "@vitest/browser-playwright"; +import deno from "@deno/vite-plugin"; + +export default defineConfig({ + plugins: [deno()], + test: { + globals: true, + browser: { + provider: playwright({ + launchOptions: { + // needed to access WebGL in headless + args: ["--use-gl=angle", "--use-angle=swiftshader"], + }, + }), + enabled: true, + instances: [ + { browser: "chromium", headless: true }, + ], + }, + }, +});