From 03a1f4451733a7862b14159780642e15f986905c Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sun, 6 Sep 2026 20:54:12 +0200 Subject: [PATCH] fix: restore strict noUncheckedIndexedAccess and skip redundant docker build in CI --- .github/workflows/ci.yml | 2 ++ src/services/queries.ts | 2 +- tests/docker.test.ts | 42 +++++++++++++++++++++++++++++--- tests/services.test.ts | 52 ++++++++++++++++++++-------------------- tests/tools.test.ts | 26 ++++++++++---------- tsconfig.json | 2 +- 6 files changed, 82 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 304820f..84194a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,8 @@ jobs: - name: Run test suite run: bun test + env: + SKIP_DOCKER_TESTS: "true" docker: name: Build & Publish Docker Image diff --git a/src/services/queries.ts b/src/services/queries.ts index cdcd13f..d4ad709 100644 --- a/src/services/queries.ts +++ b/src/services/queries.ts @@ -90,7 +90,7 @@ export async function getQueryResults( if (err instanceof OpenProjectNotFoundError) { const queryDetail = await opClient.get(`queries/${id}`); const resultsLink = queryDetail._links?.results; - const resultsHref = Array.isArray(resultsLink) ? resultsLink[0]?.href : resultsLink?.href; + const resultsHref = Array.isArray(resultsLink) ? resultsLink?.[0]?.href : resultsLink?.href; if (typeof resultsHref === "string" && resultsHref.length > 0) { const response = await opClient.get>(resultsHref, query); return unpackCollection(response, normalizeWorkPackageSummary); diff --git a/tests/docker.test.ts b/tests/docker.test.ts index 32e7628..ba4b9a9 100644 --- a/tests/docker.test.ts +++ b/tests/docker.test.ts @@ -31,6 +31,11 @@ describe("Docker Container Packaging", () => { }); test("Docker image builds and starts up cleanly in container", async () => { + if (process.env.SKIP_DOCKER_TESTS === "true" || process.env.CI === "true") { + console.log("Skipping live docker build test in CI / SKIP_DOCKER_TESTS environment"); + return; + } + // Check if docker daemon is available in test environment const proc = Bun.spawn(["which", "docker"], { stdout: "pipe", stderr: "pipe" }); const exitCode = await proc.exited; @@ -70,12 +75,43 @@ describe("Docker Container Packaging", () => { } ); - // Give it 1.5s to start and write banner to stderr - await Bun.sleep(1500); + // Read stderr with a timeout loop up to 5000ms waiting for the startup banner + let stderrText = ""; + const decoder = new TextDecoder(); + const reader = runProc.stderr.getReader(); + + const readLoop = (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + stderrText += decoder.decode(value, { stream: true }); + if (stderrText.includes("[openproject-mcp] Server started (readOnly=true)")) { + break; + } + } + } catch { + // stream closed or cancelled + } + })(); + + const startTime = Date.now(); + while ( + Date.now() - startTime < 5000 && + !stderrText.includes("[openproject-mcp] Server started (readOnly=true)") + ) { + await Bun.sleep(50); + } + runProc.kill(); await runProc.exited; + try { + await reader.cancel(); + } catch { + // ignore + } + await readLoop; - const stderrText = await new Response(runProc.stderr).text(); expect(stderrText).toContain("[openproject-mcp] Server started (readOnly=true)"); }, 60000); }); diff --git a/tests/services.test.ts b/tests/services.test.ts index 7679a02..bb1a5e7 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -115,9 +115,9 @@ describe("Projects Service", () => { expect(requestedUrl).toContain("offset=1"); expect(requestedUrl).toContain("sortBy="); expect(result.total).toBe(1); - expect(result.items[0].id).toBe(4); - expect(result.items[0].identifier).toBe("mcp-test-project"); - expect(result.items[0].name).toBe("MCP Test Project"); + expect(result.items[0]!.id).toBe(4); + expect(result.items[0]!.identifier).toBe("mcp-test-project"); + expect(result.items[0]!.name).toBe("MCP Test Project"); }); test("getProject retrieves project detail by id or identifier", async () => { @@ -304,10 +304,10 @@ describe("Work Packages Service", () => { expect(requestedUrl).toContain("filters="); expect(requestedUrl).toContain("pageSize=25"); expect(result.total).toBe(1); - expect(result.items[0].id).toBe(38); - expect(result.items[0].subject).toBe("Implement MCP Server Core Protocol"); - expect(result.items[0].status).toBe("In progress"); - expect(result.items[0].type).toBe("Task"); + expect(result.items[0]!.id).toBe(38); + expect(result.items[0]!.subject).toBe("Implement MCP Server Core Protocol"); + expect(result.items[0]!.status).toBe("In progress"); + expect(result.items[0]!.type).toBe("Task"); }); test("getWorkPackage returns full details with parent and children", async () => { @@ -329,7 +329,7 @@ describe("Work Packages Service", () => { expect(wp.parent?.id).toBe(30); expect(wp.parent?.subject).toBe("Epic Parent"); expect(wp.children?.length).toBe(2); - expect(wp.children?.[0].id).toBe(39); + expect(wp.children?.[0]?.id).toBe(39); expect(wp.lockVersion).toBe(3); }); @@ -425,9 +425,9 @@ describe("Queries Service", () => { expect(requestedUrl).toContain("pageSize=20"); const decodedUrl = decodeURIComponent(requestedUrl); expect(decodedUrl).toContain('"project"'); - expect(result.items[0].id).toBe(30); - expect(result.items[0].name).toBe("MCP Active Tasks"); - expect(result.items[0].projectId).toBe(4); + expect(result.items[0]!.id).toBe(30); + expect(result.items[0]!.name).toBe("MCP Active Tasks"); + expect(result.items[0]!.projectId).toBe(4); }); test("getQuery returns normalized details with columns and filters", async () => { @@ -448,9 +448,9 @@ describe("Queries Service", () => { expect(query.name).toBe("MCP Active Tasks"); expect(query.columns).toContain("ID"); expect(query.columns).toContain("Subject"); - expect(query.sortBy?.[0].attribute).toBe("id"); - expect(query.sortBy?.[0].direction).toBe("asc"); - expect(query.filters?.[0].field).toBe("Status"); + expect(query.sortBy?.[0]?.attribute).toBe("id"); + expect(query.sortBy?.[0]?.direction).toBe("asc"); + expect(query.filters?.[0]?.field).toBe("Status"); expect(query.resultsHref).toBe("/api/v3/queries/30/results"); }); @@ -493,8 +493,8 @@ describe("Queries Service", () => { const result = await getQueryResults(30, { pageSize: 10 }, client); expect(requestedUrl).toContain("/api/v3/queries/30/results"); expect(requestedUrl).toContain("pageSize=10"); - expect(result.items[0].id).toBe(38); - expect(result.items[0].subject).toBe("Implement MCP Server Core Protocol"); + expect(result.items[0]!.id).toBe(38); + expect(result.items[0]!.subject).toBe("Implement MCP Server Core Protocol"); }); }); @@ -523,9 +523,9 @@ describe("Metadata Service", () => { const statuses = await listStatuses(client); expect(statuses.length).toBe(2); - expect(statuses[0].name).toBe("New"); - expect(statuses[0].isClosed).toBe(false); - expect(statuses[1].isClosed).toBe(true); + expect(statuses[0]!.name).toBe("New"); + expect(statuses[0]!.isClosed).toBe(false); + expect(statuses[1]!.isClosed).toBe(true); }); test("listTypes scopes to project when projectId is provided", async () => { @@ -551,7 +551,7 @@ describe("Metadata Service", () => { const types = await listTypes({ projectId: 4 }, client); expect(requestedUrl).toContain("/api/v3/projects/4/types"); - expect(types[0].name).toBe("Task"); + expect(types[0]!.name).toBe("Task"); }); test("listTypes fetches global types when projectId is omitted", async () => { @@ -578,7 +578,7 @@ describe("Metadata Service", () => { const types = await listTypes(undefined, client); expect(requestedUrl).toContain("/api/v3/types"); expect(requestedUrl).not.toContain("/projects/"); - expect(types[0].name).toBe("Task"); + expect(types[0]!.name).toBe("Task"); }); test("listPriorities retrieves priority items", async () => { @@ -601,7 +601,7 @@ describe("Metadata Service", () => { }); const priorities = await listPriorities(client); - expect(priorities[0].name).toBe("High"); + expect(priorities[0]!.name).toBe("High"); }); test("listUsers returns paginated UserItem list", async () => { @@ -631,8 +631,8 @@ describe("Metadata Service", () => { const users = await listUsers({ pageSize: 25, offset: 1 }, client); expect(requestedUrl).toContain("/api/v3/users"); - expect(users.items[0].login).toBe("admin"); - expect(users.items[0].admin).toBe(true); + expect(users.items[0]!.login).toBe("admin"); + expect(users.items[0]!.admin).toBe(true); }); test("src/services/index.ts exports all services properly", () => { @@ -673,8 +673,8 @@ describe("Live Container Integration (Domain Services)", () => { const wps = await domainServices.listWorkPackages({ projectId: "mcp-test-project" }, client); expect(wps.items.length).toBeGreaterThan(0); - const firstWp = await domainServices.getWorkPackage(wps.items[0].id, client); - expect(firstWp.id).toBe(wps.items[0].id); + const firstWp = await domainServices.getWorkPackage(wps.items[0]!.id, client); + expect(firstWp.id).toBe(wps.items[0]!.id); expect(firstWp.subject).toBeDefined(); const searchRes = await domainServices.searchWorkPackages("MCP", { projectId: "mcp-test-project" }, client); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 215b177..a3aeab2 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -1059,7 +1059,7 @@ describe("Tool Execution Wrapper", () => { >; } )._registeredTools["wrapped_tool"]; - const result = await registered.handler({}); + const result = await registered!.handler({}); expect(wrapperCalled).toBe(true); expect(result.content[0]?.text).toBe("success"); }); @@ -1101,7 +1101,7 @@ describe("Tool Execution Wrapper", () => { } )._registeredTools["param_wrapped_tool"]; - const result = await registered.handler({ id: 123 }); + const result = await registered!.handler({ id: 123 }); expect(capturedToolName).toBe("param_wrapped_tool"); expect(capturedArgs).toEqual({ id: 123 }); expect(result.content[0]?.text).toBe("id: 123"); @@ -1146,7 +1146,7 @@ describe("Tool Execution Wrapper", () => { } as unknown as OpenProjectClient; await runWithContext({ client: dummyClient, isReadOnly: false }, () => - listProjects.handler({}) + listProjects!.handler({}) ); expect(interceptedTools).toContain("openproject_list_projects"); @@ -1163,7 +1163,7 @@ describe("Live Container Integration (All 10 MCP Tools)", () => { await runWithContext({ client: liveClient, isReadOnly: false }, async () => { const listRes = await handleListProjects({ pageSize: 5 }); expect(listRes.isError).toBeUndefined(); - const listData = JSON.parse(listRes.content[0].text); + const listData = JSON.parse(listRes.content[0]!.text); expect(listData.projects.length).toBeGreaterThan(0); const targetProject = @@ -1172,7 +1172,7 @@ describe("Live Container Integration (All 10 MCP Tools)", () => { ) || listData.projects[0]; const getRes = await handleGetProject({ projectId: targetProject.id }); expect(getRes.isError).toBeUndefined(); - const getData = JSON.parse(getRes.content[0].text); + const getData = JSON.parse(getRes.content[0]!.text); expect(getData.id).toBe(targetProject.id); }); }); @@ -1181,13 +1181,13 @@ describe("Live Container Integration (All 10 MCP Tools)", () => { await runWithContext({ client: liveClient, isReadOnly: false }, async () => { const listRes = await handleListWorkPackages({ pageSize: 5 }); expect(listRes.isError).toBeUndefined(); - const listData = JSON.parse(listRes.content[0].text); + const listData = JSON.parse(listRes.content[0]!.text); expect(listData.workPackages.length).toBeGreaterThan(0); const wpId = listData.workPackages[0].id; const getRes = await handleGetWorkPackage({ workPackageId: wpId }); expect(getRes.isError).toBeUndefined(); - const getData = JSON.parse(getRes.content[0].text); + const getData = JSON.parse(getRes.content[0]!.text); expect(getData.id).toBe(wpId); expect(getData.subject).toBeDefined(); }); @@ -1197,13 +1197,13 @@ describe("Live Container Integration (All 10 MCP Tools)", () => { await runWithContext({ client: liveClient, isReadOnly: false }, async () => { const listRes = await handleListQueries({}); expect(listRes.isError).toBeUndefined(); - const listData = JSON.parse(listRes.content[0].text); + const listData = JSON.parse(listRes.content[0]!.text); expect(listData.queries.length).toBeGreaterThan(0); const queryId = listData.queries[0].id; const getRes = await handleGetQuery({ queryId }); expect(getRes.isError).toBeUndefined(); - const getData = JSON.parse(getRes.content[0].text); + const getData = JSON.parse(getRes.content[0]!.text); expect(getData.query.id).toBe(queryId); expect(getData.results).toBeDefined(); }); @@ -1213,22 +1213,22 @@ describe("Live Container Integration (All 10 MCP Tools)", () => { await runWithContext({ client: liveClient, isReadOnly: false }, async () => { const typesRes = await handleListTypes({}); expect(typesRes.isError).toBeUndefined(); - const typesData = JSON.parse(typesRes.content[0].text); + const typesData = JSON.parse(typesRes.content[0]!.text); expect(typesData.types.length).toBeGreaterThan(0); const statusesRes = await handleListStatuses({}); expect(statusesRes.isError).toBeUndefined(); - const statusesData = JSON.parse(statusesRes.content[0].text); + const statusesData = JSON.parse(statusesRes.content[0]!.text); expect(statusesData.statuses.length).toBeGreaterThan(0); const prioritiesRes = await handleListPriorities({}); expect(prioritiesRes.isError).toBeUndefined(); - const prioritiesData = JSON.parse(prioritiesRes.content[0].text); + const prioritiesData = JSON.parse(prioritiesRes.content[0]!.text); expect(prioritiesData.priorities.length).toBeGreaterThan(0); const usersRes = await handleListUsers({ pageSize: 5 }); expect(usersRes.isError).toBeUndefined(); - const usersData = JSON.parse(usersRes.content[0].text); + const usersData = JSON.parse(usersRes.content[0]!.text); expect(usersData.users.length).toBeGreaterThan(0); }); }); diff --git a/tsconfig.json b/tsconfig.json index b5ad52e..b8a7ffc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": false, + "noUncheckedIndexedAccess": true, "noImplicitOverride": true, // Developer ergonomic options