diff --git a/apps/documentation/components/packages/service-registry/ServicePreview.tsx b/apps/documentation/components/packages/service-registry/ServicePreview.tsx index bb40e0d3..cca9ba1c 100644 --- a/apps/documentation/components/packages/service-registry/ServicePreview.tsx +++ b/apps/documentation/components/packages/service-registry/ServicePreview.tsx @@ -8,9 +8,6 @@ export default function ServicePreview() { console.log('Service data updated:', event.detail.service); }; - // `baseUrl` rather than `provider`: the component reads `provider` only on - // its first update, which runs before React sets it on the server-rendered - // element, but it reloads when `baseUrl` changes return ( ; } - // `baseUrl` rather than `provider`: the component reads `provider` only on - // its first update, which runs before React sets it on the server-rendered - // element, but it reloads when `baseUrl` changes return ( diff --git a/apps/documentation/components/packages/trs/ToolsPreview.tsx b/apps/documentation/components/packages/trs/ToolsPreview.tsx index 0f4c7cb2..08ba2818 100644 --- a/apps/documentation/components/packages/trs/ToolsPreview.tsx +++ b/apps/documentation/components/packages/trs/ToolsPreview.tsx @@ -4,9 +4,6 @@ import PreviewLayout from '@/components/common/PreviewLayout'; import { endpoints } from '@/utils/endpoints'; export default function ToolsPreview() { - // `baseUrl` rather than `provider`: the component reads `provider` only on - // its first update, which runs before React sets it on the server-rendered - // element, but it reloads when `baseUrl` changes return ( diff --git a/apps/documentation/pages/docs/providers.mdx b/apps/documentation/pages/docs/providers.mdx index e15d4442..a0026f9c 100644 --- a/apps/documentation/pages/docs/providers.mdx +++ b/apps/documentation/pages/docs/providers.mdx @@ -43,6 +43,13 @@ const provider = new RestProvider('https://api.example.com'); // 4. Handle events/states as needed ``` +You can set or replace a component's provider at any time, also after the +component has rendered (as with React, which sets it on a server-rendered +element only after the first update): the component then loads its data from +the new provider. Create the provider once and pass the same instance on every +render (e.g. with `useMemo` in React), as each new instance makes the component +load its data again. + ## Common Use Cases ### Environment-Based Configuration diff --git a/package.json b/package.json index d45cea53..6ba6c9a9 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "lint:fix": "eslint . --fix && turbo run lint:fix && prettier .", "new": "turbo gen", "prepare": "husky install", - "test": "turbo run test --parallel", + "test": "turbo run test --concurrency=3", "test:watch": "turbo run test --watch" }, "devDependencies": { diff --git a/packages/ecc-client-elixir-trs-filer/package.json b/packages/ecc-client-elixir-trs-filer/package.json index e423dd70..ff973330 100644 --- a/packages/ecc-client-elixir-trs-filer/package.json +++ b/packages/ecc-client-elixir-trs-filer/package.json @@ -38,6 +38,7 @@ "build": "node ../../scripts/tailwind/index.js && node ../../scripts/build.js -p ecc-client-elixir-trs-filer-", "dev": "concurrently -r \"node ../../scripts/tailwind/index.js --watch\" \"node ../../scripts/build.js -p ecc-client-elixir-trs-filer- --watch\" \"wds\"", "clean": "rm -rf dist node_modules custom-elements-manifest.config.js src/output.css src/tailwind.ts", + "test": "web-test-runner --group default --config ../../web-test.runner-config.mjs --port 8006", "test:watch": "wtr --watch", "lint": "npx eslint .", "lint:fix": "npm run lint -- --fix", diff --git a/packages/ecc-client-elixir-trs-filer/src/components/tool-create/tests/tool-create.test.ts b/packages/ecc-client-elixir-trs-filer/src/components/tool-create/tests/tool-create.test.ts new file mode 100644 index 00000000..24b8b102 --- /dev/null +++ b/packages/ecc-client-elixir-trs-filer/src/components/tool-create/tests/tool-create.test.ts @@ -0,0 +1,152 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect, fixture, html, oneEvent, waitUntil } from "@open-wc/testing"; +import sinon from "sinon"; +import "../index.js"; +import type { ECCClientElixirTrsToolCreate } from "../tool-create.js"; +import type { TrsFilerProvider } from "../../../providers/index.js"; + +const BASE_URL = "https://trs.example.org/ga4gh/trs/v2"; +const OTHER_URL = "https://other.example.org/ga4gh/trs/v2"; + +type ToolClass = { id: string; name: string }; + +/** + * A provider whose tool class list is a stub; the form loads nothing else + * before a submit. + */ +const stubProvider = ( + classes: ToolClass[] | Promise = [ + { id: "workflow", name: "Workflow" }, + ] +) => { + const stubs = { + getToolClasses: sinon.stub().callsFake(async () => classes), + }; + return { stubs, provider: stubs as unknown as TrsFilerProvider }; +}; + +/** A promise and the function that resolves it. */ +const deferred = () => { + let settleWith: (value: T) => void = () => undefined; + const promise = new Promise((resolve) => { + settleWith = resolve; + }); + return { promise, resolve: settleWith }; +}; + +/** The tool classes the form offers. */ +const offered = (el: ECCClientElixirTrsToolCreate) => + (el as unknown as { toolClasses: ToolClass[] }).toolClasses; + +describe("ecc-client-elixir-trs-tool-create provider", () => { + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sinon.stub(window, "fetch").callsFake( + async () => + new Response(JSON.stringify([]), { + headers: { "Content-Type": "application/json" }, + }) + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("loads from a provider set after the first update", async () => { + const el = await fixture( + html`` + ); + const { stubs, provider } = stubProvider(); + + el.provider = provider; + await waitUntil(() => offered(el)[0]?.id === "workflow"); + + expect(stubs.getToolClasses.callCount).to.equal(1); + expect(fetchStub.callCount).to.equal(0); + }); + + it("loads from a provider that replaces the first one", async () => { + const first = stubProvider(); + const second = stubProvider([{ id: "tool", name: "Tool" }]); + const el = await fixture( + html`` + ); + await waitUntil(() => offered(el)[0]?.id === "workflow"); + + el.provider = second.provider; + await waitUntil(() => offered(el)[0]?.id === "tool"); + + expect(first.stubs.getToolClasses.callCount).to.equal(1); + }); + + it("drops a slower load from a replaced provider", async () => { + const slow = deferred(); + const first = stubProvider(slow.promise); + const second = stubProvider([{ id: "tool", name: "Tool" }]); + const el = await fixture( + html`` + ); + + el.provider = second.provider; + await waitUntil(() => offered(el)[0]?.id === "tool"); + slow.resolve([{ id: "workflow", name: "Workflow" }]); + await slow.promise; + await el.updateComplete; + + expect(offered(el)[0].id).to.equal("tool"); + }); + + it("keeps a provider when the base URL is set too", async () => { + const { stubs, provider } = stubProvider(); + const el = await fixture( + html`` + ); + await waitUntil(() => stubs.getToolClasses.callCount === 1); + + el.baseUrl = BASE_URL; + await el.updateComplete; + + expect(stubs.getToolClasses.callCount).to.equal(1); + expect(fetchStub.callCount).to.equal(0); + }); + + it("loads from the base URL and reloads when it changes", async () => { + const el = await fixture( + html`` + ); + await waitUntil(() => fetchStub.callCount === 1); + + el.baseUrl = OTHER_URL; + await waitUntil(() => fetchStub.callCount === 2); + + expect(fetchStub.firstCall.args[0]).to.equal(`${BASE_URL}/toolClasses`); + expect(fetchStub.secondCall.args[0]).to.equal(`${OTHER_URL}/toolClasses`); + }); + + it("offers no tool classes once the provider is removed without a base URL", async () => { + const { provider } = stubProvider(); + const el = await fixture( + html`` + ); + await waitUntil(() => offered(el).length === 1); + const failed = oneEvent(el, "ecc-tool-create-validation-failed"); + + el.provider = undefined; + await el.updateComplete; + + expect(offered(el)).to.deep.equal([]); + expect((await failed).detail.error).to.contain("Please provide either"); + }); +}); diff --git a/packages/ecc-client-elixir-trs-filer/src/components/tool-create/tool-create.ts b/packages/ecc-client-elixir-trs-filer/src/components/tool-create/tool-create.ts index 4cbd0499..d6976ed0 100644 --- a/packages/ecc-client-elixir-trs-filer/src/components/tool-create/tool-create.ts +++ b/packages/ecc-client-elixir-trs-filer/src/components/tool-create/tool-create.ts @@ -227,6 +227,9 @@ export class ECCClientElixirTrsToolCreate extends LitElement { private _provider: TrsFilerProvider | null = null; + /** The REST provider for `baseUrl`, used without a custom provider */ + private restProvider: RestTrsFilerProvider | null = null; + @state() private activeFileIndex: { [versionIndex: number]: number } = {}; @state() private activeDescriptorType: { [versionIndex: number]: DescriptorType | "ALL"; @@ -253,7 +256,8 @@ export class ECCClientElixirTrsToolCreate extends LitElement { if (this.provider) { this._provider = this.provider; } else if (this.baseUrl) { - this._provider = new RestTrsFilerProvider(this.baseUrl); + this.restProvider = new RestTrsFilerProvider(this.baseUrl); + this._provider = this.restProvider; } else { this._provider = null; } @@ -337,18 +341,58 @@ export class ECCClientElixirTrsToolCreate extends LitElement { this.initializeFormWithDefaults(); } - if (changedProperties.has("baseUrl") && this.baseUrl) { - this._provider = new RestTrsFilerProvider(this.baseUrl); - this.loadToolClasses(); + if ( + (changedProperties.has("provider") || changedProperties.has("baseUrl")) && + this.useProvider() + ) { + if (this._provider) { + this.loadToolClasses(); + } else { + this.toolClasses = []; + this.dispatchEvent( + new CustomEvent("ecc-tool-create-validation-failed", { + detail: { + error: + "Please provide either a base URL for the TRS API or a custom provider.", + }, + bubbles: true, + composed: true, + }) + ); + } + } + } + + /** + * Switches to the custom provider or, without one, to a REST provider for + * the base URL (to none without either); returns whether the provider in + * use changed + */ + private useProvider(): boolean { + let provider: TrsFilerProvider | null = this.provider ?? null; + if (!provider && this.baseUrl) { + if (this.restProvider?.baseUrl !== this.baseUrl) { + this.restProvider = new RestTrsFilerProvider(this.baseUrl); + } + provider = this.restProvider; } + if (provider === this._provider) return false; + this._provider = provider; + return true; } private async loadToolClasses(): Promise { - if (!this._provider) return; + const provider = this._provider; + if (!provider) return; try { - this.toolClasses = await this._provider.getToolClasses(); + const toolClasses = await provider.getToolClasses(); + // a load from a provider that was replaced in the meantime is dropped + if (provider !== this._provider) return; + this.toolClasses = toolClasses; } catch (error) { + if (provider !== this._provider) return; + this.toolClasses = []; console.error("Failed to load tool classes:", error); this.dispatchEvent( new CustomEvent("ecc-tool-create-failed", { diff --git a/packages/ecc-client-elixir-trs-filer/src/providers/tests/rest-trs-filer-provider.test.ts b/packages/ecc-client-elixir-trs-filer/src/providers/tests/rest-trs-filer-provider.test.ts new file mode 100644 index 00000000..9fa6c2fa --- /dev/null +++ b/packages/ecc-client-elixir-trs-filer/src/providers/tests/rest-trs-filer-provider.test.ts @@ -0,0 +1,343 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect } from "@open-wc/testing"; +import sinon from "sinon"; +import { RestTrsFilerProvider } from "../rest-trs-filer-provider.js"; +import type { ToolRegister } from "../trs-filer-provider.js"; + +const BASE_URL = "https://trs.example.org/ga4gh/trs/v2"; +const TOOL = `${BASE_URL}/tools/tool%201`; +const TOOL_REGISTER: ToolRegister = { + organization: "Example", + toolclass: { id: "workflow", name: "Workflow" }, + versions: [], +}; +const VERSION = { name: "v1" }; +const TOOL_CLASS = { name: "Workflow" }; + +/** Provider that adds a bearer token to every request, as in the docs. */ +class TokenTrsFilerProvider extends RestTrsFilerProvider { + protected fetch(input: RequestInfo | URL, init: RequestInit = {}) { + const headers = new Headers(init.headers); + headers.set("Authorization", "Bearer test-token"); + return super.fetch(input, { ...init, headers }); + } +} + +type Call = [ + string, + (provider: RestTrsFilerProvider) => Promise, + string +]; + +/** Every provider method, with the URL it requests and its error message. */ +const CALLS: Call[] = [ + ["getToolClasses", (p) => p.getToolClasses(), `${BASE_URL}/toolClasses`], + ["getTool", (p) => p.getTool(BASE_URL, "tool 1"), TOOL], + [ + "getToolVersions", + (p) => p.getToolVersions(BASE_URL, "tool 1"), + `${TOOL}/versions`, + ], + [ + "getToolVersion", + (p) => p.getToolVersion(BASE_URL, "tool 1", "tool 1:v1"), + `${TOOL}/versions/v1`, + ], + [ + "getToolFiles", + (p) => p.getToolFiles(BASE_URL, "tool 1", "v1", "CWL"), + `${TOOL}/versions/v1/CWL/files`, + ], + [ + "getToolDescriptor", + (p) => p.getToolDescriptor(BASE_URL, "tool 1", "tool 1:v1", "WDL"), + `${TOOL}/versions/v1/WDL/descriptor`, + ], + [ + "getContainerfile", + (p) => p.getContainerfile(BASE_URL, "tool 1", "v1"), + `${TOOL}/versions/v1/containerfile`, + ], + [ + "getToolTests", + (p) => p.getToolTests(BASE_URL, "tool 1", "tool 1:v1", "NFL"), + `${TOOL}/versions/v1/NFL/tests`, + ], +]; + +const ok = (body: unknown = {}) => + new Response(JSON.stringify(body), { status: 200 }); +const failed = () => + new Response("{}", { status: 404, statusText: "Not Found" }); + +describe("RestTrsFilerProvider", () => { + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sinon.stub(window, "fetch").callsFake(async () => ok()); + }); + + afterEach(() => { + sinon.restore(); + }); + + CALLS.forEach(([name, call, url]) => { + it(`${name} requests ${url.slice(BASE_URL.length)}`, async () => { + fetchStub.callsFake(async () => ok({ name })); + + const result = await call(new RestTrsFilerProvider(BASE_URL)); + + expect(fetchStub.callCount).to.equal(1); + expect(fetchStub.firstCall.args[0]).to.equal(url); + expect(result).to.deep.equal({ name }); + }); + + it(`${name} throws on an error response`, async () => { + fetchStub.callsFake(async () => failed()); + + let error: Error | undefined; + try { + await call(new RestTrsFilerProvider(BASE_URL)); + } catch (e) { + error = e as Error; + } + + expect(error?.message).to.match(/^Failed to fetch .*: Not Found$/); + }); + + it(`${name} sends its request through an overridden fetch`, async () => { + await call(new TokenTrsFilerProvider(BASE_URL)); + + const headers = new Headers(fetchStub.firstCall.args[1]?.headers); + expect(headers.get("Authorization")).to.equal("Bearer test-token"); + }); + }); + + it("getToolFiles asks for a ZIP archive", async () => { + await new RestTrsFilerProvider(BASE_URL).getToolFiles( + BASE_URL, + "tool 1", + "v1", + "CWL", + "zip" + ); + + expect(fetchStub.firstCall.args[0]).to.equal( + `${TOOL}/versions/v1/CWL/files?format=zip` + ); + }); + + describe("getToolsList", () => { + it("requests the first page without parameters", async () => { + await new RestTrsFilerProvider(BASE_URL).getToolsList(0, 0, {}, ""); + + expect(fetchStub.firstCall.args[0]).to.equal(`${BASE_URL}/tools?`); + }); + + it("adds paging, the query and set filters", async () => { + await new RestTrsFilerProvider(BASE_URL).getToolsList( + 10, + 20, + { + offset: "5", + toolClass: "Workflow", + checker: false, + author: "", + organization: undefined, + }, + "my tool" + ); + + expect(fetchStub.firstCall.args[0]).to.equal( + `${BASE_URL}/tools?limit=10&offset=20&` + + "&name=my%20tool&toolClass=Workflow&checker=false" + ); + }); + + it("throws on an error response", async () => { + fetchStub.callsFake(async () => failed()); + + let error: Error | undefined; + try { + await new RestTrsFilerProvider(BASE_URL).getToolsList(10, 0, {}, ""); + } catch (e) { + error = e as Error; + } + + expect(error?.message).to.equal("Failed to fetch tools: Not Found"); + }); + }); + + describe("getToolDescriptorByPath", () => { + const DESCRIPTOR = `${TOOL}/versions/v1/CWL/descriptor`; + const byPath = (provider: RestTrsFilerProvider) => + provider.getToolDescriptorByPath( + BASE_URL, + "tool 1", + "tool 1:v1", + "CWL", + "dir/main.cwl" + ); + + it("requests the unencoded path first", async () => { + fetchStub.callsFake(async () => ok({ content: "plain" })); + + const result = await byPath(new RestTrsFilerProvider(BASE_URL)); + + expect(fetchStub.callCount).to.equal(1); + expect(fetchStub.firstCall.args[0]).to.equal( + `${DESCRIPTOR}/dir/main.cwl` + ); + expect(result).to.deep.equal({ content: "plain" }); + }); + + it("falls back to the encoded path after an error response", async () => { + fetchStub.onFirstCall().callsFake(async () => failed()); + fetchStub.onSecondCall().callsFake(async () => ok({ content: "enc" })); + + const result = await byPath(new RestTrsFilerProvider(BASE_URL)); + + expect(fetchStub.callCount).to.equal(2); + expect(fetchStub.secondCall.args[0]).to.equal( + `${DESCRIPTOR}/dir%2Fmain.cwl` + ); + expect(result).to.deep.equal({ content: "enc" }); + }); + + it("falls back to the encoded path after a network error", async () => { + fetchStub.onFirstCall().rejects(new TypeError("Network down")); + fetchStub.onSecondCall().callsFake(async () => ok({ content: "enc" })); + + const result = await byPath(new RestTrsFilerProvider(BASE_URL)); + + expect(fetchStub.secondCall.args[0]).to.equal( + `${DESCRIPTOR}/dir%2Fmain.cwl` + ); + expect(result).to.deep.equal({ content: "enc" }); + }); + + it("throws the first error if both paths fail", async () => { + fetchStub.callsFake(async () => failed()); + + let error: Error | undefined; + try { + await byPath(new RestTrsFilerProvider(BASE_URL)); + } catch (e) { + error = e as Error; + } + + expect(error?.message).to.equal( + "Failed to fetch tool descriptor by path: Not Found" + ); + }); + + it("throws the network error if the encoded path fails too", async () => { + fetchStub.onFirstCall().rejects(new TypeError("Network down")); + fetchStub.onSecondCall().callsFake(async () => failed()); + + let error: Error | undefined; + try { + await byPath(new RestTrsFilerProvider(BASE_URL)); + } catch (e) { + error = e as Error; + } + + expect(error?.message).to.equal("Network down"); + }); + }); + + type Write = [ + string, + (provider: RestTrsFilerProvider) => Promise, + string, + string, + unknown, + string + ]; + + /** Every creation method: URL, HTTP method, body and error message. */ + const WRITES: Write[] = [ + [ + "createTool", + (p) => p.createTool(TOOL_REGISTER), + `${BASE_URL}/tools`, + "POST", + TOOL_REGISTER, + "Failed to create tool", + ], + [ + "createToolWithId", + (p) => p.createToolWithId("tool 1", TOOL_REGISTER), + TOOL, + "PUT", + TOOL_REGISTER, + "Failed to create/update tool", + ], + [ + "createToolVersion", + (p) => p.createToolVersion("tool 1", VERSION), + `${TOOL}/versions`, + "POST", + VERSION, + "Failed to create tool version", + ], + [ + "createToolVersionWithId", + (p) => p.createToolVersionWithId("tool 1", "v 1", VERSION), + `${TOOL}/versions/v%201`, + "PUT", + VERSION, + "Failed to create/update tool version", + ], + [ + "createToolClass", + (p) => p.createToolClass(TOOL_CLASS), + `${BASE_URL}/toolClasses`, + "POST", + TOOL_CLASS, + "Failed to create tool class", + ], + [ + "createToolClassWithId", + (p) => p.createToolClassWithId("class 1", TOOL_CLASS), + `${BASE_URL}/toolClasses/class%201`, + "PUT", + TOOL_CLASS, + "Failed to create/update tool class", + ], + ]; + + WRITES.forEach(([name, call, url, method, body, message]) => { + it(`${name} sends ${method} ${url.slice(BASE_URL.length)}`, async () => { + sinon.stub(console, "log"); + fetchStub.callsFake(async () => new Response("new-id", { status: 200 })); + + const result = await call(new TokenTrsFilerProvider(BASE_URL)); + + const [requestUrl, init] = fetchStub.firstCall.args; + const headers = new Headers(init.headers); + expect(requestUrl).to.equal(url); + expect(init.method).to.equal(method); + expect(JSON.parse(init.body)).to.deep.equal(body); + expect(headers.get("Content-Type")).to.equal("application/json"); + expect(headers.get("Authorization")).to.equal("Bearer test-token"); + expect(result).to.equal("new-id"); + }); + + it(`${name} throws on an error response`, async () => { + sinon.stub(console, "log"); + fetchStub.callsFake( + async () => new Response("", { status: 409, statusText: "Conflict" }) + ); + + let error: Error | undefined; + try { + await call(new RestTrsFilerProvider(BASE_URL)); + } catch (e) { + error = e as Error; + } + + expect(error?.message).to.equal(`${message}: Conflict`); + }); + }); +}); diff --git a/packages/ecc-client-ga4gh-drs/package.json b/packages/ecc-client-ga4gh-drs/package.json index e671a5b5..522719da 100644 --- a/packages/ecc-client-ga4gh-drs/package.json +++ b/packages/ecc-client-ga4gh-drs/package.json @@ -38,6 +38,7 @@ "build": "node ../../scripts/tailwind/index.js && node ../../scripts/build.js -p ecc-client-ga4gh-drs-", "dev": "concurrently -r \"node ../../scripts/tailwind/index.js --watch\" \"node ../../scripts/build.js -p ecc-client-ga4gh-drs- --watch\" \"wds\"", "clean": "rm -rf dist node_modules custom-elements-manifest.config.js src/output.css src/tailwind.ts", + "test": "web-test-runner --group default --config ../../web-test.runner-config.mjs --port 8007", "test:watch": "wtr --watch", "lint": "npx eslint .", "lint:fix": "npm run lint -- --fix", diff --git a/packages/ecc-client-ga4gh-drs/src/components/objects-list/objects.ts b/packages/ecc-client-ga4gh-drs/src/components/objects-list/objects.ts index c0fdd991..6cdf1374 100644 --- a/packages/ecc-client-ga4gh-drs/src/components/objects-list/objects.ts +++ b/packages/ecc-client-ga4gh-drs/src/components/objects-list/objects.ts @@ -52,6 +52,9 @@ export class ECCClientGa4ghDrsObjects extends LitElement { private _provider: DrsProvider | null = null; + /** The REST provider for `baseUrl`, used without a custom provider */ + private restProvider: RestDrsProvider | null = null; + protected async firstUpdated(): Promise { if (!this.baseUrl && !this.provider) { this.error = @@ -62,7 +65,8 @@ export class ECCClientGa4ghDrsObjects extends LitElement { if (this.provider) { this._provider = this.provider; } else if (this.baseUrl) { - this._provider = new RestDrsProvider(this.baseUrl); + this.restProvider = new RestDrsProvider(this.baseUrl); + this._provider = this.restProvider; } else { this._provider = null; } @@ -77,24 +81,64 @@ export class ECCClientGa4ghDrsObjects extends LitElement { this.loadData(); } - if (changedProperties.has("baseUrl") && this.baseUrl) { - this._provider = new RestDrsProvider(this.baseUrl); - this.loadData(); + if ( + (changedProperties.has("provider") || changedProperties.has("baseUrl")) && + this.useProvider() + ) { + // another provider starts on its first page + this.currentPage = 1; + this.totalPages = 0; + if (this._provider) { + this.loadData(); + } else { + this.objects = []; + this.loading = false; + this.dispatchEvent( + new CustomEvent("ecc-objects-changed", { + detail: { objects: [] }, + bubbles: true, + composed: true, + }) + ); + this.error = + "Please provide either a base URL for the DRS API or a custom provider."; + } + } + } + + /** + * Switches to the custom provider or, without one, to a REST provider for + * the base URL (to none without either); returns whether the provider in + * use changed + */ + private useProvider(): boolean { + let provider: DrsProvider | null = this.provider ?? null; + if (!provider && this.baseUrl) { + if (this.restProvider?.baseUrl !== this.baseUrl) { + this.restProvider = new RestDrsProvider(this.baseUrl); + } + provider = this.restProvider; } + if (provider === this._provider) return false; + this._provider = provider; + return true; } private async loadData(): Promise { - if (!this._provider) return; + const provider = this._provider; + if (!provider) return; this.loading = true; this.error = null; try { // API treats offset as page number, not actual offset - const result = await this._provider.getObjects( + const result = await provider.getObjects( this.pageSize, this.currentPage - 1 ); + // a load from a provider that was replaced in the meantime is dropped + if (provider !== this._provider) return; this.objects = result.objects; // Update total objects and pages from API response @@ -128,10 +172,12 @@ export class ECCClientGa4ghDrsObjects extends LitElement { }) ); } catch (err) { + if (provider !== this._provider) return; + this.objects = []; this.error = err instanceof Error ? err.message : "Failed to load objects"; } finally { - this.loading = false; + if (provider === this._provider) this.loading = false; } } diff --git a/packages/ecc-client-ga4gh-drs/src/components/objects-list/tests/objects.test.ts b/packages/ecc-client-ga4gh-drs/src/components/objects-list/tests/objects.test.ts new file mode 100644 index 00000000..03c9b264 --- /dev/null +++ b/packages/ecc-client-ga4gh-drs/src/components/objects-list/tests/objects.test.ts @@ -0,0 +1,169 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect, fixture, html, waitUntil } from "@open-wc/testing"; +import sinon from "sinon"; +import "../index.js"; +import type { ECCClientGa4ghDrsObjects } from "../objects.js"; +import type { + DrsObject, + DrsProvider, +} from "../../../providers/drs-provider.js"; + +const BASE_URL = "https://drs.example.org/ga4gh/drs/v1"; +const OTHER_URL = "https://other.example.org/ga4gh/drs/v1"; +const MISSING = "Please provide either a base URL"; + +const object = (id: string) => ({ id } as unknown as DrsObject); + +type Page = { objects: DrsObject[] }; + +/** + * A provider whose object list is a stub; the list uses no other method + * before an object is selected. + */ +const stubProvider = ( + page: Page | Promise = { objects: [object("a")] } +) => { + const stubs = { + getObjects: sinon.stub().callsFake(async () => page), + }; + return { stubs, provider: stubs as unknown as DrsProvider }; +}; + +/** A promise and the function that resolves it. */ +const deferred = () => { + let settleWith: (value: T) => void = () => undefined; + const promise = new Promise((resolve) => { + settleWith = resolve; + }); + return { promise, resolve: settleWith }; +}; + +/** The component's private state that the tests read and set. */ +const internals = (el: ECCClientGa4ghDrsObjects) => + el as unknown as { objects: DrsObject[]; currentPage: number }; + +describe("ecc-client-ga4gh-drs-objects provider", () => { + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sinon.stub(window, "fetch").callsFake( + async () => + new Response(JSON.stringify({ objects: [] }), { + headers: { "Content-Type": "application/json" }, + }) + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("loads from a provider set after the first update", async () => { + const el = await fixture( + html`` + ); + expect(el.shadowRoot?.textContent).to.contain(MISSING); + const { stubs, provider } = stubProvider(); + + el.provider = provider; + await waitUntil(() => internals(el).objects.length === 1); + await el.updateComplete; + + expect(stubs.getObjects.callCount).to.equal(1); + expect(el.shadowRoot?.textContent).not.to.contain(MISSING); + expect(fetchStub.callCount).to.equal(0); + }); + + it("loads the first page from a provider that replaces the first one", async () => { + const first = stubProvider(); + const second = stubProvider({ objects: [object("b")] }); + const el = await fixture( + html`` + ); + await waitUntil(() => internals(el).objects[0]?.id === "a"); + internals(el).currentPage = 3; + const firstCalls = first.stubs.getObjects.callCount; + + el.provider = second.provider; + await waitUntil(() => internals(el).objects[0]?.id === "b"); + + expect(internals(el).currentPage).to.equal(1); + expect(second.stubs.getObjects.firstCall.args[1]).to.equal(0); + expect(first.stubs.getObjects.callCount).to.equal(firstCalls); + }); + + it("drops a slower load from a replaced provider", async () => { + const slow = deferred(); + const first = stubProvider(slow.promise); + const second = stubProvider({ objects: [object("b")] }); + const el = await fixture( + html`` + ); + + el.provider = second.provider; + await waitUntil(() => internals(el).objects[0]?.id === "b"); + slow.resolve({ objects: [object("a")] }); + await slow.promise; + await el.updateComplete; + + expect(internals(el).objects[0].id).to.equal("b"); + }); + + it("keeps a provider when the base URL is set too", async () => { + const { stubs, provider } = stubProvider(); + const el = await fixture( + html`` + ); + await waitUntil(() => internals(el).objects.length === 1); + const calls = stubs.getObjects.callCount; + + el.baseUrl = BASE_URL; + await el.updateComplete; + + expect(stubs.getObjects.callCount).to.equal(calls); + expect(fetchStub.callCount).to.equal(0); + }); + + it("loads from the base URL and reloads when it changes", async () => { + const el = await fixture( + html`` + ); + await waitUntil(() => + fetchStub + .getCalls() + .some((call) => String(call.args[0]).startsWith(`${BASE_URL}/objects`)) + ); + + el.baseUrl = OTHER_URL; + await waitUntil(() => + fetchStub + .getCalls() + .some((call) => String(call.args[0]).startsWith(`${OTHER_URL}/objects`)) + ); + }); + + it("shows an error once the provider is removed without a base URL", async () => { + const { provider } = stubProvider(); + const el = await fixture( + html`` + ); + await waitUntil(() => internals(el).objects.length === 1); + + el.provider = undefined; + await el.updateComplete; + await el.updateComplete; + + expect(internals(el).objects).to.deep.equal([]); + expect(el.shadowRoot?.textContent).to.contain(MISSING); + }); +}); diff --git a/packages/ecc-client-ga4gh-drs/src/providers/tests/rest-drs-provider.test.ts b/packages/ecc-client-ga4gh-drs/src/providers/tests/rest-drs-provider.test.ts new file mode 100644 index 00000000..640cdbb3 --- /dev/null +++ b/packages/ecc-client-ga4gh-drs/src/providers/tests/rest-drs-provider.test.ts @@ -0,0 +1,103 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect } from "@open-wc/testing"; +import sinon from "sinon"; +import { RestDrsProvider } from "../rest-drs-provider.js"; + +const BASE_URL = "https://drs.example.org/ga4gh/drs/v1"; + +/** Provider that adds a bearer token to every request, as in the docs. */ +class TokenDrsProvider extends RestDrsProvider { + protected fetch(input: RequestInfo | URL, init: RequestInit = {}) { + const headers = new Headers(init.headers); + headers.set("Authorization", "Bearer test-token"); + return super.fetch(input, { ...init, headers }); + } +} + +type Call = [string, (provider: RestDrsProvider) => Promise, string]; + +/** Every provider method, with the URL it requests. */ +const CALLS: Call[] = [ + ["getObjects", (p) => p.getObjects(), `${BASE_URL}/objects`], + ["getObject", (p) => p.getObject("obj 1"), `${BASE_URL}/objects/obj%201`], + [ + "getAccessURL", + (p) => p.getAccessURL("obj 1", "s3/a"), + `${BASE_URL}/objects/obj%201/access/s3%2Fa`, + ], + ["getServiceInfo", (p) => p.getServiceInfo(), `${BASE_URL}/service-info`], +]; + +const ok = (body: unknown = {}) => + new Response(JSON.stringify(body), { status: 200 }); + +describe("RestDrsProvider", () => { + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sinon.stub(window, "fetch").callsFake(async () => ok()); + }); + + afterEach(() => { + sinon.restore(); + }); + + CALLS.forEach(([name, call, url]) => { + it(`${name} requests ${url.slice(BASE_URL.length)}`, async () => { + await call(new RestDrsProvider(BASE_URL)); + + expect(fetchStub.callCount).to.equal(1); + expect(fetchStub.firstCall.args[0]).to.equal(url); + }); + + it(`${name} throws on an error response`, async () => { + fetchStub.callsFake( + async () => new Response("{}", { status: 500, statusText: "Oops" }) + ); + + let error: Error | undefined; + try { + await call(new RestDrsProvider(BASE_URL)); + } catch (e) { + error = e as Error; + } + + expect(error?.message).to.match(/^Failed to fetch .*: Oops$/); + }); + + it(`${name} sends its request through an overridden fetch`, async () => { + await call(new TokenDrsProvider(BASE_URL)); + + const headers = new Headers(fetchStub.firstCall.args[1]?.headers); + expect(headers.get("Authorization")).to.equal("Bearer test-token"); + }); + }); + + it("getObjects adds paging and returns objects and pagination", async () => { + const pagination = { offset: 20, limit: 10, total: 42 }; + fetchStub.callsFake(async () => + ok({ objects: [{ id: "obj-1" }], pagination }) + ); + + const result = await new RestDrsProvider(BASE_URL).getObjects(10, 20); + + expect(fetchStub.firstCall.args[0]).to.equal( + `${BASE_URL}/objects?offset=20&limit=10` + ); + expect(result).to.deep.equal({ objects: [{ id: "obj-1" }], pagination }); + }); + + it("getObjects returns no objects for a response without any", async () => { + const result = await new RestDrsProvider(BASE_URL).getObjects(); + + expect(result).to.deep.equal({ objects: [], pagination: undefined }); + }); + + it("getObject asks for expanded bundles", async () => { + await new RestDrsProvider(BASE_URL).getObject("obj-1", true); + + expect(fetchStub.firstCall.args[0]).to.equal( + `${BASE_URL}/objects/obj-1?expand=true` + ); + }); +}); diff --git a/packages/ecc-client-ga4gh-service-registry/package.json b/packages/ecc-client-ga4gh-service-registry/package.json index 148976e2..367d4fd7 100644 --- a/packages/ecc-client-ga4gh-service-registry/package.json +++ b/packages/ecc-client-ga4gh-service-registry/package.json @@ -38,6 +38,7 @@ "build": "node ../../scripts/tailwind/index.js && node ../../scripts/build.js -p ecc-client-ga4gh-service-registry-", "dev": "concurrently -r \"node ../../scripts/tailwind/index.js --watch\" \"node ../../scripts/build.js -p ecc-client-ga4gh-service-registry- --watch\" \"wds\"", "clean": "rm -rf dist node_modules custom-elements-manifest.config.js src/output.css src/tailwind.ts", + "test": "web-test-runner --group default --config ../../web-test.runner-config.mjs --port 8004", "test:watch": "wtr --watch", "lint": "npx eslint .", "lint:fix": "npm run lint -- --fix", diff --git a/packages/ecc-client-ga4gh-service-registry/src/components/service/service.ts b/packages/ecc-client-ga4gh-service-registry/src/components/service/service.ts index f3b09aae..524ba9ec 100644 --- a/packages/ecc-client-ga4gh-service-registry/src/components/service/service.ts +++ b/packages/ecc-client-ga4gh-service-registry/src/components/service/service.ts @@ -48,6 +48,9 @@ export class ECCClientGa4ghServiceRegistryService extends LitElement { private _provider: ServiceRegistryProvider | null = null; + /** The REST provider for `baseUrl`, used without a custom provider */ + private restProvider: RestServiceRegistryProvider | null = null; + protected async firstUpdated(): Promise { if (!this.baseUrl && !this.provider) { this.error = @@ -63,7 +66,8 @@ export class ECCClientGa4ghServiceRegistryService extends LitElement { if (this.provider) { this._provider = this.provider; } else if (this.baseUrl) { - this._provider = new RestServiceRegistryProvider(this.baseUrl); + this.restProvider = new RestServiceRegistryProvider(this.baseUrl); + this._provider = this.restProvider; } else { this._provider = null; } @@ -74,27 +78,68 @@ export class ECCClientGa4ghServiceRegistryService extends LitElement { } protected updated(changedProperties: Map): void { - if ( - (changedProperties.has("baseUrl") && this.baseUrl) || - (changedProperties.has("serviceId") && this.serviceId) + const providerChanged = + (changedProperties.has("provider") || changedProperties.has("baseUrl")) && + this.useProvider(); + if (providerChanged && !this._provider) { + this.service = null; + this.dispatchEvent( + new CustomEvent("ecc-service-changed", { + detail: { service: null }, + bubbles: true, + composed: true, + }) + ); + this.loading = false; + this.error = + "Please provide either a base URL for the Service Registry API or a custom provider."; + } else if (changedProperties.has("serviceId") && !this.serviceId) { + this.service = null; + this.loading = false; + this.error = "Please provide a service ID."; + } else if ( + (providerChanged || + (changedProperties.has("serviceId") && this.serviceId)) && + this.serviceId && + this._provider ) { - if (this.baseUrl) { - this._provider = new RestServiceRegistryProvider(this.baseUrl); - } - if (this.serviceId && this._provider) { - this.loadData(); + this.loadData(); + } + } + + /** + * Switches to the custom provider or, without one, to a REST provider for + * the base URL (to none without either); returns whether the provider in + * use changed + */ + private useProvider(): boolean { + let provider: ServiceRegistryProvider | null = this.provider ?? null; + if (!provider && this.baseUrl) { + if (this.restProvider?.baseUrl !== this.baseUrl) { + this.restProvider = new RestServiceRegistryProvider(this.baseUrl); } + provider = this.restProvider; } + if (provider === this._provider) return false; + this._provider = provider; + return true; } private async loadData(): Promise { - if (!this._provider || !this.serviceId) return; + const provider = this._provider; + const { serviceId } = this; + if (!provider || !serviceId) return; + // a load for a provider or service that was replaced in the meantime is + // dropped + const replaced = () => + provider !== this._provider || serviceId !== this.serviceId; this.loading = true; this.error = null; try { - const service = await this._provider.getServiceById(this.serviceId); + const service = await provider.getServiceById(serviceId); + if (replaced()) return; this.service = service; // Emit an event with the updated service data @@ -106,6 +151,8 @@ export class ECCClientGa4ghServiceRegistryService extends LitElement { }) ); } catch (err) { + if (replaced()) return; + this.service = null; this.error = err instanceof Error ? err.message : "Failed to load service"; console.error({ @@ -113,7 +160,7 @@ export class ECCClientGa4ghServiceRegistryService extends LitElement { breakPoint: "ECCClientGa4ghServiceRegistryService.loadData", }); } finally { - this.loading = false; + if (!replaced()) this.loading = false; } } diff --git a/packages/ecc-client-ga4gh-service-registry/src/components/service/tests/service.test.ts b/packages/ecc-client-ga4gh-service-registry/src/components/service/tests/service.test.ts new file mode 100644 index 00000000..d6bb05b7 --- /dev/null +++ b/packages/ecc-client-ga4gh-service-registry/src/components/service/tests/service.test.ts @@ -0,0 +1,193 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect, fixture, html, waitUntil } from "@open-wc/testing"; +import sinon from "sinon"; +import "../index.js"; +import type { ECCClientGa4ghServiceRegistryService } from "../service.js"; +import type { + ExternalService, + ServiceRegistryProvider, +} from "../../../providers/index.js"; + +const BASE_URL = "https://registry.example.org/ga4gh/registry/v1"; +const OTHER_URL = "https://other.example.org/ga4gh/registry/v1"; +const MISSING = "Please provide either a base URL"; + +const service = (id: string): ExternalService => ({ + id, + name: `Service ${id}`, + type: { group: "org.ga4gh", artifact: "tes", version: "1.1.0" }, + organization: { name: "Example", url: "https://example.org" }, + version: "1.0.0", + url: "https://tes.example.org", +}); + +/** + * A provider whose methods are stubs; `getServiceById` returns the given + * service, or one with the requested ID. + */ +const stubProvider = (result?: Promise) => + ({ + getServices: sinon.stub().rejects(new Error("not used")), + getServiceById: sinon + .stub() + .callsFake(async (id: string) => result ?? service(id)), + getServiceTypes: sinon.stub().rejects(new Error("not used")), + getServiceInfo: sinon.stub().rejects(new Error("not used")), + } satisfies ServiceRegistryProvider); + +/** A promise and the function that resolves it. */ +const deferred = () => { + let settleWith: (value: T) => void = () => undefined; + const promise = new Promise((resolve) => { + settleWith = resolve; + }); + return { promise, resolve: settleWith }; +}; + +/** The service the component shows. */ +const shown = (el: ECCClientGa4ghServiceRegistryService) => + (el as unknown as { service: ExternalService | null }).service; + +describe("ecc-client-ga4gh-service-registry-service provider", () => { + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sinon.stub(window, "fetch").callsFake( + async () => + new Response(JSON.stringify(service("a")), { + headers: { "Content-Type": "application/json" }, + }) + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("loads from a provider set after the first update", async () => { + const el = await fixture( + html`` + ); + expect(el.shadowRoot?.textContent).to.contain(MISSING); + const provider = stubProvider(); + + el.provider = provider; + await waitUntil(() => shown(el)?.id === "a"); + await el.updateComplete; + + expect(provider.getServiceById.callCount).to.equal(1); + expect(el.shadowRoot?.textContent).not.to.contain(MISSING); + expect(fetchStub.callCount).to.equal(0); + }); + + it("loads from a provider that replaces the first one", async () => { + const first = stubProvider(); + const replacement = { ...service("a"), name: "Replacement" }; + const second = stubProvider(Promise.resolve(replacement)); + const el = await fixture( + html`` + ); + await waitUntil(() => shown(el)?.id === "a"); + const firstCalls = first.getServiceById.callCount; + + el.provider = second; + await waitUntil(() => shown(el)?.name === "Replacement"); + + expect(second.getServiceById.callCount).to.equal(1); + expect(first.getServiceById.callCount).to.equal(firstCalls); + }); + + it("drops a slower load from a replaced provider", async () => { + const slow = deferred(); + const first = stubProvider(slow.promise); + const second = stubProvider(); + const el = await fixture( + html`` + ); + + el.provider = second; + await waitUntil(() => shown(el)?.id === "b"); + slow.resolve(service("stale")); + await slow.promise; + await el.updateComplete; + + expect(shown(el)?.id).to.equal("b"); + }); + + it("keeps a provider when the base URL is set too", async () => { + const provider = stubProvider(); + const el = await fixture( + html`` + ); + await waitUntil(() => shown(el)?.id === "a"); + const calls = provider.getServiceById.callCount; + + el.baseUrl = BASE_URL; + await el.updateComplete; + + expect(provider.getServiceById.callCount).to.equal(calls); + expect(fetchStub.callCount).to.equal(0); + }); + + it("loads from the base URL and reloads when it changes", async () => { + const el = await fixture( + html`` + ); + await waitUntil(() => fetchStub.callCount > 0); + + el.baseUrl = OTHER_URL; + await waitUntil(() => + fetchStub + .getCalls() + .some((call) => call.args[0] === `${OTHER_URL}/services/a`) + ); + + expect(fetchStub.firstCall.args[0]).to.equal(`${BASE_URL}/services/a`); + }); + + it("loads another service from the same provider", async () => { + const provider = stubProvider(); + const el = await fixture( + html`` + ); + await waitUntil(() => shown(el)?.id === "a"); + + el.serviceId = "b"; + await waitUntil(() => shown(el)?.id === "b"); + }); + + it("shows an error once the provider is removed without a base URL", async () => { + const provider = stubProvider(); + const el = await fixture( + html`` + ); + await waitUntil(() => shown(el)?.id === "a"); + + el.provider = undefined; + await el.updateComplete; + await el.updateComplete; + + expect(shown(el)).to.equal(null); + expect(el.shadowRoot?.textContent).to.contain(MISSING); + }); +}); diff --git a/packages/ecc-client-ga4gh-service-registry/src/components/services/services.ts b/packages/ecc-client-ga4gh-service-registry/src/components/services/services.ts index 9918a1f3..43237606 100644 --- a/packages/ecc-client-ga4gh-service-registry/src/components/services/services.ts +++ b/packages/ecc-client-ga4gh-service-registry/src/components/services/services.ts @@ -56,6 +56,9 @@ export class ECCClientGa4ghServiceRegistryServices extends LitElement { private _provider: ServiceRegistryProvider | null = null; + /** The REST provider for `baseUrl`, used without a custom provider */ + private restProvider: RestServiceRegistryProvider | null = null; + protected async firstUpdated(): Promise { if (!this.baseUrl && !this.provider) { this.error = @@ -66,7 +69,8 @@ export class ECCClientGa4ghServiceRegistryServices extends LitElement { if (this.provider) { this._provider = this.provider; } else if (this.baseUrl) { - this._provider = new RestServiceRegistryProvider(this.baseUrl); + this.restProvider = new RestServiceRegistryProvider(this.baseUrl); + this._provider = this.restProvider; } else { this._provider = null; } @@ -78,31 +82,74 @@ export class ECCClientGa4ghServiceRegistryServices extends LitElement { } protected updated(changedProperties: Map): void { - if (changedProperties.has("baseUrl") && this.baseUrl) { - this._provider = new RestServiceRegistryProvider(this.baseUrl); - this.loadData(); - this.loadServiceTypes(); + if ( + (changedProperties.has("provider") || changedProperties.has("baseUrl")) && + this.useProvider() + ) { + if (this._provider) { + this.loadData(); + this.loadServiceTypes(); + } else { + this.services = []; + this.loading = false; + this.serviceTypes = []; + this.dispatchEvent( + new CustomEvent("ecc-services-changed", { + detail: { services: [] }, + bubbles: true, + composed: true, + }) + ); + this.error = + "Please provide either a base URL for the Service Registry API or a custom provider."; + } } } + /** + * Switches to the custom provider or, without one, to a REST provider for + * the base URL (to none without either); returns whether the provider in + * use changed + */ + private useProvider(): boolean { + let provider: ServiceRegistryProvider | null = this.provider ?? null; + if (!provider && this.baseUrl) { + if (this.restProvider?.baseUrl !== this.baseUrl) { + this.restProvider = new RestServiceRegistryProvider(this.baseUrl); + } + provider = this.restProvider; + } + if (provider === this._provider) return false; + this._provider = provider; + return true; + } + private async loadServiceTypes(): Promise { - if (!this._provider) return; + const provider = this._provider; + if (!provider) return; try { - this.serviceTypes = await this._provider.getServiceTypes(); + const serviceTypes = await provider.getServiceTypes(); + // a load from a provider that was replaced in the meantime is dropped + if (provider !== this._provider) return; + this.serviceTypes = serviceTypes; } catch (error) { + if (provider === this._provider) this.serviceTypes = []; console.error("Failed to load service types:", error); } } private async loadData(): Promise { - if (!this._provider) return; + const provider = this._provider; + if (!provider) return; this.loading = true; this.error = null; try { - const services = await this._provider.getServices(); + const services = await provider.getServices(); + // a load from a provider that was replaced in the meantime is dropped + if (provider !== this._provider) return; this.services = services; // Emit an event with the updated services @@ -114,6 +161,8 @@ export class ECCClientGa4ghServiceRegistryServices extends LitElement { }) ); } catch (err) { + if (provider !== this._provider) return; + this.services = []; this.error = err instanceof Error ? err.message : "Failed to load services"; console.error({ @@ -121,7 +170,7 @@ export class ECCClientGa4ghServiceRegistryServices extends LitElement { breakPoint: "ECCClientGa4ghServiceRegistryServices.loadData", }); } finally { - this.loading = false; + if (provider === this._provider) this.loading = false; } } @@ -146,7 +195,8 @@ export class ECCClientGa4ghServiceRegistryServices extends LitElement { } const query = this.searchQuery.toLowerCase(); - if (!this._provider) return; + const provider = this._provider; + if (!provider) return; this.loading = true; this.error = null; @@ -154,7 +204,9 @@ export class ECCClientGa4ghServiceRegistryServices extends LitElement { try { // For now, we'll just filter client-side // In a real implementation, you might want to do this on the server - this._provider.getServices().then((services) => { + provider.getServices().then((services) => { + // a load from a provider that was replaced in the meantime is dropped + if (provider !== this._provider) return; this.services = services.filter( (service) => service.id.toLowerCase().includes(query) || diff --git a/packages/ecc-client-ga4gh-service-registry/src/components/services/tests/services.test.ts b/packages/ecc-client-ga4gh-service-registry/src/components/services/tests/services.test.ts new file mode 100644 index 00000000..1b0ff015 --- /dev/null +++ b/packages/ecc-client-ga4gh-service-registry/src/components/services/tests/services.test.ts @@ -0,0 +1,231 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect, fixture, html, oneEvent, waitUntil } from "@open-wc/testing"; +import sinon from "sinon"; +import "../index.js"; +import type { ECCClientGa4ghServiceRegistryServices } from "../services.js"; +import type { + ExternalService, + ServiceRegistryProvider, +} from "../../../providers/index.js"; +import { RestServiceRegistryProvider } from "../../../providers/rest-sr-provider.js"; + +const BASE_URL = "https://registry.example.org/ga4gh/registry/v1"; +const OTHER_URL = "https://other.example.org/ga4gh/registry/v1"; +const MISSING = "Please provide either a base URL"; + +const service = (id: string): ExternalService => ({ + id, + name: `Service ${id}`, + type: { group: "org.ga4gh", artifact: "tes", version: "1.1.0" }, + organization: { name: "Example", url: "https://example.org" }, + version: "1.0.0", + url: "https://tes.example.org", +}); + +/** A provider whose methods are stubs. */ +const stubProvider = ( + services: ExternalService[] | Promise = [service("a")] +) => + ({ + getServices: sinon.stub().callsFake(async () => services), + getServiceById: sinon.stub().rejects(new Error("not used")), + getServiceTypes: sinon.stub().resolves([]), + getServiceInfo: sinon.stub().rejects(new Error("not used")), + } satisfies ServiceRegistryProvider); + +/** A promise and the function that resolves it. */ +const deferred = () => { + let settleWith: (value: T) => void = () => undefined; + const promise = new Promise((resolve) => { + settleWith = resolve; + }); + return { promise, resolve: settleWith }; +}; + +/** The services the component shows. */ +const shown = (el: ECCClientGa4ghServiceRegistryServices) => + (el as unknown as { services: ExternalService[] }).services; + +describe("ecc-client-ga4gh-service-registry-services provider", () => { + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sinon.stub(window, "fetch").callsFake( + async () => + new Response(JSON.stringify([]), { + headers: { "Content-Type": "application/json" }, + }) + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("loads from a provider set after the first update", async () => { + const el = await fixture( + html`` + ); + expect(el.shadowRoot?.textContent).to.contain(MISSING); + const provider = stubProvider(); + + el.provider = provider; + await waitUntil(() => shown(el).length === 1); + await el.updateComplete; + + expect(provider.getServices.callCount).to.equal(1); + expect(provider.getServiceTypes.callCount).to.equal(1); + expect(el.shadowRoot?.textContent).not.to.contain(MISSING); + expect(fetchStub.callCount).to.equal(0); + }); + + it("loads from a provider that replaces the first one", async () => { + const first = stubProvider(); + const second = stubProvider([service("b")]); + const el = await fixture( + html`` + ); + await waitUntil(() => shown(el)[0]?.id === "a"); + + el.provider = second; + await waitUntil(() => shown(el)[0]?.id === "b"); + + expect(second.getServices.callCount).to.equal(1); + expect(first.getServices.callCount).to.equal(1); + }); + + it("drops a slower load from a replaced provider", async () => { + const slow = deferred(); + const first = stubProvider(slow.promise); + const second = stubProvider([service("b")]); + const el = await fixture( + html`` + ); + + el.provider = second; + await waitUntil(() => shown(el)[0]?.id === "b"); + slow.resolve([service("a")]); + await slow.promise; + await el.updateComplete; + + expect(shown(el)[0].id).to.equal("b"); + }); + + it("keeps a provider when the base URL is set too", async () => { + const provider = stubProvider(); + const el = await fixture( + html`` + ); + await waitUntil(() => shown(el).length === 1); + + el.baseUrl = OTHER_URL; + await el.updateComplete; + + expect(provider.getServices.callCount).to.equal(1); + expect(fetchStub.callCount).to.equal(0); + }); + + it("loads from the base URL and reloads when it changes", async () => { + const el = await fixture( + html`` + ); + await waitUntil(() => fetchStub.callCount === 2); + + el.baseUrl = OTHER_URL; + await waitUntil(() => fetchStub.callCount === 4); + + const urls = fetchStub.getCalls().map((call) => call.args[0]); + expect(urls).to.include(`${BASE_URL}/services`); + expect(urls).to.include(`${OTHER_URL}/services`); + }); + + it("uses the base URL once the provider is removed", async () => { + const provider = stubProvider(); + const el = await fixture( + html`` + ); + await waitUntil(() => shown(el).length === 1); + + el.provider = undefined; + await waitUntil(() => fetchStub.callCount === 2); + + expect(fetchStub.firstCall.args[0]).to.equal(`${BASE_URL}/services`); + expect(provider.getServices.callCount).to.equal(1); + }); + + it("uses a REST provider of its own once a custom REST provider is removed", async () => { + // a custom provider that is a REST provider for the same base URL + class CustomProvider extends RestServiceRegistryProvider { + requests = 0; + + protected fetch(input: RequestInfo | URL, init?: RequestInit) { + this.requests += 1; + return super.fetch(input, init); + } + } + const provider = new CustomProvider(BASE_URL); + const el = await fixture( + html`` + ); + await waitUntil(() => fetchStub.callCount === 2); + const { requests } = provider; + + el.provider = undefined; + await waitUntil(() => fetchStub.callCount === 4); + + expect(provider.requests).to.equal(requests); + }); + + it("shows an error once the provider is removed without a base URL", async () => { + const provider = stubProvider(); + const el = await fixture( + html`` + ); + await waitUntil(() => shown(el).length === 1); + const changed = oneEvent(el, "ecc-services-changed"); + + el.provider = undefined; + await el.updateComplete; + await el.updateComplete; + + expect(shown(el)).to.deep.equal([]); + expect(el.shadowRoot?.textContent).to.contain(MISSING); + expect((await changed).detail.services).to.deep.equal([]); + }); + + it("shows no services of a replaced provider when the new one fails", async () => { + const first = stubProvider(); + const second = stubProvider(); + second.getServices.callsFake(async () => { + throw new Error("Unavailable"); + }); + const el = await fixture( + html`` + ); + await waitUntil(() => shown(el).length === 1); + + el.provider = second; + await waitUntil(() => el.shadowRoot?.textContent?.includes("Unavailable")); + + expect(shown(el)).to.deep.equal([]); + }); +}); diff --git a/packages/ecc-client-ga4gh-service-registry/src/providers/tests/rest-sr-provider.test.ts b/packages/ecc-client-ga4gh-service-registry/src/providers/tests/rest-sr-provider.test.ts new file mode 100644 index 00000000..03dfeb81 --- /dev/null +++ b/packages/ecc-client-ga4gh-service-registry/src/providers/tests/rest-sr-provider.test.ts @@ -0,0 +1,83 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect } from "@open-wc/testing"; +import sinon from "sinon"; +import { RestServiceRegistryProvider } from "../rest-sr-provider.js"; + +const BASE_URL = "https://registry.example.org/ga4gh/registry/v1"; + +/** Provider that adds a bearer token to every request, as in the docs. */ +class TokenServiceRegistryProvider extends RestServiceRegistryProvider { + protected fetch(input: RequestInfo | URL, init: RequestInit = {}) { + const headers = new Headers(init.headers); + headers.set("Authorization", "Bearer test-token"); + return super.fetch(input, { ...init, headers }); + } +} + +type Call = [ + string, + (provider: RestServiceRegistryProvider) => Promise, + string +]; + +/** Every provider method, with the URL it requests. */ +const CALLS: Call[] = [ + ["getServices", (p) => p.getServices(), `${BASE_URL}/services`], + [ + "getServiceById", + (p) => p.getServiceById("org/service 1"), + `${BASE_URL}/services/org%2Fservice%201`, + ], + ["getServiceTypes", (p) => p.getServiceTypes(), `${BASE_URL}/services/types`], + ["getServiceInfo", (p) => p.getServiceInfo(), `${BASE_URL}/service-info`], +]; + +describe("RestServiceRegistryProvider", () => { + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sinon + .stub(window, "fetch") + .callsFake(async () => new Response("{}", { status: 200 })); + }); + + afterEach(() => { + sinon.restore(); + }); + + CALLS.forEach(([name, call, url]) => { + it(`${name} requests ${url.slice(BASE_URL.length)}`, async () => { + fetchStub.callsFake( + async () => new Response(JSON.stringify({ name }), { status: 200 }) + ); + + const result = await call(new RestServiceRegistryProvider(BASE_URL)); + + expect(fetchStub.callCount).to.equal(1); + expect(fetchStub.firstCall.args[0]).to.equal(url); + expect(result).to.deep.equal({ name }); + }); + + it(`${name} throws on an error response`, async () => { + fetchStub.callsFake( + async () => new Response("{}", { status: 500, statusText: "Oops" }) + ); + + let error: Error | undefined; + try { + await call(new RestServiceRegistryProvider(BASE_URL)); + } catch (e) { + error = e as Error; + } + + expect(error?.message).to.match(/^Failed to fetch .*: Oops$/); + }); + + it(`${name} sends its request through an overridden fetch`, async () => { + await call(new TokenServiceRegistryProvider(BASE_URL)); + + const headers = new Headers(fetchStub.firstCall.args[1]?.headers); + expect(headers.get("Authorization")).to.equal("Bearer test-token"); + }); + }); +}); diff --git a/packages/ecc-client-ga4gh-trs/package.json b/packages/ecc-client-ga4gh-trs/package.json index 3548ce1d..b2b3c580 100644 --- a/packages/ecc-client-ga4gh-trs/package.json +++ b/packages/ecc-client-ga4gh-trs/package.json @@ -38,6 +38,7 @@ "build": "node ../../scripts/tailwind/index.js && node ../../scripts/build.js -p ecc-client-ga4gh-trs-", "dev": "concurrently -r \"node ../../scripts/tailwind/index.js --watch\" \"node ../../scripts/build.js -p ecc-client-ga4gh-trs- --watch\" \"wds\"", "clean": "rm -rf dist node_modules custom-elements-manifest.config.js src/output.css src/tailwind.ts", + "test": "web-test-runner --group default --config ../../web-test.runner-config.mjs --port 8005", "test:watch": "wtr --watch", "lint": "npx eslint .", "lint:fix": "npm run lint -- --fix", diff --git a/packages/ecc-client-ga4gh-trs/src/components/tools/tests/tools.test.ts b/packages/ecc-client-ga4gh-trs/src/components/tools/tests/tools.test.ts new file mode 100644 index 00000000..c12c3219 --- /dev/null +++ b/packages/ecc-client-ga4gh-trs/src/components/tools/tests/tools.test.ts @@ -0,0 +1,205 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect, fixture, html, waitUntil } from "@open-wc/testing"; +import sinon from "sinon"; +import "../index.js"; +import type { ECCClientGa4ghTrsTools } from "../tools.js"; +import type { Tool, ToolClass, TrsProvider } from "../../../providers/index.js"; + +const BASE_URL = "https://trs.example.org/ga4gh/trs/v2"; +const OTHER_URL = "https://other.example.org/ga4gh/trs/v2"; +const MISSING = "Please provide either a base URL"; + +const TOOL_CLASS: ToolClass = { id: "workflow", name: "Workflow" }; + +const tool = (id: string): Tool => ({ + id, + url: `${BASE_URL}/tools/${id}`, + organization: "Example", + toolclass: TOOL_CLASS, + versions: [], +}); + +/** + * A provider whose list methods are stubs; the tools list uses no other + * methods. + */ +const stubProvider = (tools: Tool[] | Promise = [tool("a")]) => { + const stubs = { + getToolClasses: sinon.stub().resolves([TOOL_CLASS]), + getToolsList: sinon.stub().callsFake(async () => tools), + }; + return { stubs, provider: stubs as unknown as TrsProvider }; +}; + +/** A promise and the function that resolves it. */ +const deferred = () => { + let settleWith: (value: T) => void = () => undefined; + const promise = new Promise((resolve) => { + settleWith = resolve; + }); + return { promise, resolve: settleWith }; +}; + +/** The component's private state that the tests read and set. */ +const internals = (el: ECCClientGa4ghTrsTools) => + el as unknown as { + tools: Tool[]; + currentPage: number; + lastPage: number; + loading: boolean; + }; + +describe("ecc-client-ga4gh-trs-tools provider", () => { + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sinon.stub(window, "fetch").callsFake( + async () => + new Response(JSON.stringify([]), { + headers: { "Content-Type": "application/json" }, + }) + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("loads from a provider set after the first update", async () => { + const el = await fixture( + html`` + ); + expect(el.shadowRoot?.textContent).to.contain(MISSING); + const { stubs, provider } = stubProvider(); + + el.provider = provider; + await waitUntil(() => internals(el).tools.length === 1); + await el.updateComplete; + + expect(stubs.getToolsList.callCount).to.equal(1); + expect(stubs.getToolClasses.callCount).to.equal(1); + expect(el.shadowRoot?.textContent).not.to.contain(MISSING); + expect(fetchStub.callCount).to.equal(0); + }); + + it("loads the first page from a provider that replaces the first one", async () => { + const first = stubProvider(); + const second = stubProvider([tool("b")]); + const el = await fixture( + html`` + ); + await waitUntil(() => internals(el).tools[0]?.id === "a"); + internals(el).currentPage = 3; + internals(el).lastPage = 3; + const firstCalls = first.stubs.getToolsList.callCount; + + el.provider = second.provider; + await waitUntil(() => internals(el).tools[0]?.id === "b"); + + expect(internals(el).currentPage).to.equal(1); + expect(second.stubs.getToolsList.firstCall.args[1]).to.equal(0); + expect(first.stubs.getToolsList.callCount).to.equal(firstCalls); + }); + + it("drops a slower load from a replaced provider", async () => { + const slow = deferred(); + const first = stubProvider(slow.promise); + const second = stubProvider([tool("b")]); + const el = await fixture( + html`` + ); + + el.provider = second.provider; + await waitUntil(() => internals(el).tools[0]?.id === "b"); + slow.resolve([tool("a")]); + await slow.promise; + await el.updateComplete; + + expect(internals(el).tools[0].id).to.equal("b"); + }); + + it("keeps a provider when the base URL is set too", async () => { + const { stubs, provider } = stubProvider(); + const el = await fixture( + html`` + ); + await waitUntil(() => stubs.getToolClasses.callCount === 1); + + el.baseUrl = BASE_URL; + await el.updateComplete; + + expect(stubs.getToolClasses.callCount).to.equal(1); + expect(fetchStub.callCount).to.equal(0); + }); + + it("loads from the base URL and reloads when it changes", async () => { + const el = await fixture( + html`` + ); + await waitUntil(() => + fetchStub + .getCalls() + .some((call) => call.args[0] === `${BASE_URL}/toolClasses`) + ); + + el.baseUrl = OTHER_URL; + await waitUntil(() => + fetchStub + .getCalls() + .some((call) => call.args[0] === `${OTHER_URL}/toolClasses`) + ); + + const urls = fetchStub.getCalls().map((call) => String(call.args[0])); + expect(urls.some((url) => url.startsWith(`${BASE_URL}/tools?`))).to.equal( + true + ); + expect(urls.some((url) => url.startsWith(`${OTHER_URL}/tools?`))).to.equal( + true + ); + }); + + it("shows an error once the provider is removed without a base URL", async () => { + const { provider } = stubProvider(); + const el = await fixture( + html`` + ); + await waitUntil(() => internals(el).tools.length === 1); + + el.provider = undefined; + await el.updateComplete; + await el.updateComplete; + + expect(internals(el).tools).to.deep.equal([]); + expect(el.shadowRoot?.textContent).to.contain(MISSING); + }); + + it("stops loading once the provider is removed during a load", async () => { + const slow = deferred(); + const { provider } = stubProvider(slow.promise); + const el = await fixture( + html`` + ); + expect(internals(el).loading).to.equal(true); + + el.provider = undefined; + await el.updateComplete; + slow.resolve([tool("a")]); + await slow.promise; + await el.updateComplete; + + expect(internals(el).loading).to.equal(false); + expect(internals(el).tools).to.deep.equal([]); + }); +}); diff --git a/packages/ecc-client-ga4gh-trs/src/components/tools/tools.ts b/packages/ecc-client-ga4gh-trs/src/components/tools/tools.ts index 82db5984..4d4ec4dd 100644 --- a/packages/ecc-client-ga4gh-trs/src/components/tools/tools.ts +++ b/packages/ecc-client-ga4gh-trs/src/components/tools/tools.ts @@ -77,6 +77,9 @@ export class ECCClientGa4ghTrsTools extends LitElement { private _provider: TrsProvider | null = null; + /** The REST provider for `baseUrl`, used without a custom provider */ + private restProvider: RestTrsProvider | null = null; + protected async firstUpdated(): Promise { if (!this.baseUrl && !this.provider) { this.error = @@ -87,7 +90,8 @@ export class ECCClientGa4ghTrsTools extends LitElement { if (this.provider) { this._provider = this.provider; } else if (this.baseUrl) { - this._provider = new RestTrsProvider(this.baseUrl); + this.restProvider = new RestTrsProvider(this.baseUrl); + this._provider = this.restProvider; } else { this._provider = null; } @@ -103,30 +107,78 @@ export class ECCClientGa4ghTrsTools extends LitElement { this.loadData(); } - if (changedProperties.has("baseUrl") && this.baseUrl) { - this._provider = new RestTrsProvider(this.baseUrl); - this.loadData(); - this.loadToolClasses(); + if ( + (changedProperties.has("provider") || changedProperties.has("baseUrl")) && + this.useProvider() + ) { + // another provider starts on its first page + this.currentPage = 1; + this.lastPage = -1; + if (this._provider) { + this.loadData(); + this.loadToolClasses(); + } else { + this.tools = []; + this.loading = false; + this.toolClasses = []; + this.toolClassMap = {}; + this.dispatchEvent( + new CustomEvent("ecc-tools-changed", { + detail: { tools: [] }, + bubbles: true, + composed: true, + }) + ); + this.error = + "Please provide either a base URL for the TRS API or a custom provider."; + } } } + /** + * Switches to the custom provider or, without one, to a REST provider for + * the base URL (to none without either); returns whether the provider in + * use changed + */ + private useProvider(): boolean { + let provider: TrsProvider | null = this.provider ?? null; + if (!provider && this.baseUrl) { + if (this.restProvider?.baseUrl !== this.baseUrl) { + this.restProvider = new RestTrsProvider(this.baseUrl); + } + provider = this.restProvider; + } + if (provider === this._provider) return false; + this._provider = provider; + return true; + } + private async loadToolClasses(): Promise { - if (!this._provider) return; + const provider = this._provider; + if (!provider) return; try { - this.toolClasses = await this._provider.getToolClasses(); + const toolClasses = await provider.getToolClasses(); + // a load from a provider that was replaced in the meantime is dropped + if (provider !== this._provider) return; + this.toolClasses = toolClasses; // Store a mapping of name to ID for lookup during filtering this.toolClassMap = Object.fromEntries( this.toolClasses.map((tc) => [tc.name, tc.id]) ); } catch (error) { + if (provider === this._provider) { + this.toolClasses = []; + this.toolClassMap = {}; + } console.error("Failed to load tool classes:", error); } } private async loadData(): Promise { - if (!this._provider) return; + const provider = this._provider; + if (!provider) return; this.loading = true; this.error = null; @@ -144,12 +196,14 @@ export class ECCClientGa4ghTrsTools extends LitElement { // We want to send the tool class name directly to the API, not the ID // No conversion needed here, the name is already stored in filterParams.toolClass - const tools = await this._provider.getToolsList( + const tools = await provider.getToolsList( this.pageSize < 0 ? 0 : this.pageSize, offset < 0 ? 0 : offset, apiFilterParams, this.searchQuery ); + // a load from a provider that was replaced in the meantime is dropped + if (provider !== this._provider) return; this.tools = tools; @@ -175,13 +229,15 @@ export class ECCClientGa4ghTrsTools extends LitElement { this.loadData(); } } catch (err) { + if (provider !== this._provider) return; + this.tools = []; this.error = err instanceof Error ? err.message : "Failed to load tools"; console.error({ error: this.error, breakPoint: "ECCClientGa4ghTrs.loadData", }); } finally { - this.loading = false; + if (provider === this._provider) this.loading = false; } } diff --git a/packages/ecc-client-ga4gh-trs/src/providers/tests/rest-trs-provider.test.ts b/packages/ecc-client-ga4gh-trs/src/providers/tests/rest-trs-provider.test.ts new file mode 100644 index 00000000..91f48a70 --- /dev/null +++ b/packages/ecc-client-ga4gh-trs/src/providers/tests/rest-trs-provider.test.ts @@ -0,0 +1,236 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect } from "@open-wc/testing"; +import sinon from "sinon"; +import { RestTrsProvider } from "../rest-trs-provider.js"; + +const BASE_URL = "https://trs.example.org/ga4gh/trs/v2"; +const TOOL = `${BASE_URL}/tools/tool%201`; + +/** Provider that adds a bearer token to every request, as in the docs. */ +class TokenTrsProvider extends RestTrsProvider { + protected fetch(input: RequestInfo | URL, init: RequestInit = {}) { + const headers = new Headers(init.headers); + headers.set("Authorization", "Bearer test-token"); + return super.fetch(input, { ...init, headers }); + } +} + +type Call = [string, (provider: RestTrsProvider) => Promise, string]; + +/** Every provider method, with the URL it requests and its error message. */ +const CALLS: Call[] = [ + ["getToolClasses", (p) => p.getToolClasses(), `${BASE_URL}/toolClasses`], + ["getTool", (p) => p.getTool(BASE_URL, "tool 1"), TOOL], + [ + "getToolVersions", + (p) => p.getToolVersions(BASE_URL, "tool 1"), + `${TOOL}/versions`, + ], + [ + "getToolVersion", + (p) => p.getToolVersion(BASE_URL, "tool 1", "tool 1:v1"), + `${TOOL}/versions/v1`, + ], + [ + "getToolFiles", + (p) => p.getToolFiles(BASE_URL, "tool 1", "v1", "CWL"), + `${TOOL}/versions/v1/CWL/files`, + ], + [ + "getToolDescriptor", + (p) => p.getToolDescriptor(BASE_URL, "tool 1", "tool 1:v1", "WDL"), + `${TOOL}/versions/v1/WDL/descriptor`, + ], + [ + "getContainerfile", + (p) => p.getContainerfile(BASE_URL, "tool 1", "v1"), + `${TOOL}/versions/v1/containerfile`, + ], + [ + "getToolTests", + (p) => p.getToolTests(BASE_URL, "tool 1", "tool 1:v1", "NFL"), + `${TOOL}/versions/v1/NFL/tests`, + ], +]; + +const ok = (body: unknown = {}) => + new Response(JSON.stringify(body), { status: 200 }); +const failed = () => + new Response("{}", { status: 404, statusText: "Not Found" }); + +describe("RestTrsProvider", () => { + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sinon.stub(window, "fetch").callsFake(async () => ok()); + }); + + afterEach(() => { + sinon.restore(); + }); + + CALLS.forEach(([name, call, url]) => { + it(`${name} requests ${url.slice(BASE_URL.length)}`, async () => { + fetchStub.callsFake(async () => ok({ name })); + + const result = await call(new RestTrsProvider(BASE_URL)); + + expect(fetchStub.callCount).to.equal(1); + expect(fetchStub.firstCall.args[0]).to.equal(url); + expect(result).to.deep.equal({ name }); + }); + + it(`${name} throws on an error response`, async () => { + fetchStub.callsFake(async () => failed()); + + let error: Error | undefined; + try { + await call(new RestTrsProvider(BASE_URL)); + } catch (e) { + error = e as Error; + } + + expect(error?.message).to.match(/^Failed to fetch .*: Not Found$/); + }); + + it(`${name} sends its request through an overridden fetch`, async () => { + await call(new TokenTrsProvider(BASE_URL)); + + const headers = new Headers(fetchStub.firstCall.args[1]?.headers); + expect(headers.get("Authorization")).to.equal("Bearer test-token"); + }); + }); + + it("getToolFiles asks for a ZIP archive", async () => { + await new RestTrsProvider(BASE_URL).getToolFiles( + BASE_URL, + "tool 1", + "v1", + "CWL", + "zip" + ); + + expect(fetchStub.firstCall.args[0]).to.equal( + `${TOOL}/versions/v1/CWL/files?format=zip` + ); + }); + + describe("getToolsList", () => { + it("requests the first page without parameters", async () => { + await new RestTrsProvider(BASE_URL).getToolsList(0, 0, {}, ""); + + expect(fetchStub.firstCall.args[0]).to.equal(`${BASE_URL}/tools?`); + }); + + it("adds paging, the query and set filters", async () => { + await new RestTrsProvider(BASE_URL).getToolsList( + 10, + 20, + { + offset: "5", + toolClass: "Workflow", + checker: false, + author: "", + organization: undefined, + }, + "my tool" + ); + + expect(fetchStub.firstCall.args[0]).to.equal( + `${BASE_URL}/tools?limit=10&offset=20&` + + "&name=my%20tool&toolClass=Workflow&checker=false" + ); + }); + + it("throws on an error response", async () => { + fetchStub.callsFake(async () => failed()); + + let error: Error | undefined; + try { + await new RestTrsProvider(BASE_URL).getToolsList(10, 0, {}, ""); + } catch (e) { + error = e as Error; + } + + expect(error?.message).to.equal("Failed to fetch tools: Not Found"); + }); + }); + + describe("getToolDescriptorByPath", () => { + const DESCRIPTOR = `${TOOL}/versions/v1/CWL/descriptor`; + const byPath = (provider: RestTrsProvider) => + provider.getToolDescriptorByPath( + BASE_URL, + "tool 1", + "tool 1:v1", + "CWL", + "dir/main.cwl" + ); + + it("requests the unencoded path first", async () => { + fetchStub.callsFake(async () => ok({ content: "plain" })); + + const result = await byPath(new RestTrsProvider(BASE_URL)); + + expect(fetchStub.callCount).to.equal(1); + expect(fetchStub.firstCall.args[0]).to.equal( + `${DESCRIPTOR}/dir/main.cwl` + ); + expect(result).to.deep.equal({ content: "plain" }); + }); + + it("falls back to the encoded path after an error response", async () => { + fetchStub.onFirstCall().callsFake(async () => failed()); + fetchStub.onSecondCall().callsFake(async () => ok({ content: "enc" })); + + const result = await byPath(new RestTrsProvider(BASE_URL)); + + expect(fetchStub.callCount).to.equal(2); + expect(fetchStub.secondCall.args[0]).to.equal( + `${DESCRIPTOR}/dir%2Fmain.cwl` + ); + expect(result).to.deep.equal({ content: "enc" }); + }); + + it("falls back to the encoded path after a network error", async () => { + fetchStub.onFirstCall().rejects(new TypeError("Network down")); + fetchStub.onSecondCall().callsFake(async () => ok({ content: "enc" })); + + const result = await byPath(new RestTrsProvider(BASE_URL)); + + expect(fetchStub.secondCall.args[0]).to.equal( + `${DESCRIPTOR}/dir%2Fmain.cwl` + ); + expect(result).to.deep.equal({ content: "enc" }); + }); + + it("throws the first error if both paths fail", async () => { + fetchStub.callsFake(async () => failed()); + + let error: Error | undefined; + try { + await byPath(new RestTrsProvider(BASE_URL)); + } catch (e) { + error = e as Error; + } + + expect(error?.message).to.equal( + "Failed to fetch tool descriptor by path: Not Found" + ); + }); + + it("throws the network error if the encoded path fails too", async () => { + fetchStub.onFirstCall().rejects(new TypeError("Network down")); + fetchStub.onSecondCall().callsFake(async () => failed()); + + let error: Error | undefined; + try { + await byPath(new RestTrsProvider(BASE_URL)); + } catch (e) { + error = e as Error; + } + + expect(error?.message).to.equal("Network down"); + }); + }); +}); diff --git a/web-test.runner-config.mjs b/web-test.runner-config.mjs index 84b74ebc..3493cbe1 100644 --- a/web-test.runner-config.mjs +++ b/web-test.runner-config.mjs @@ -2,8 +2,10 @@ import { esbuildPlugin } from "@web/dev-server-esbuild"; import fg from "fast-glob"; import { playwrightLauncher } from "@web/test-runner-playwright"; -// Packages run tests in parallel (turbo --parallel), so each package's test -// scripts pass a unique --port: design 8000, wes 8001, tes 8003. +// Packages run tests in parallel (at most three at once, as more overload +// the browsers on CI runners), so each package's test +// scripts pass a unique --port: design 8000, wes 8001, cloud registry 8002, +// tes 8003, service registry 8004, trs 8005, trs-filer 8006, drs 8007. export default { rootDir: ".", files: "src/**/*.test.ts", // "default" group