diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6c98159e..1d9ab7ff 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -145,10 +145,10 @@ jobs: # the diagnostic step with its own continue-on-error: true (line 203). # Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3. continue-on-error: false - # Job-level ceiling. The go test step below runs with a per-step 10m timeout; - # this cap catches any step that leaks past that. Set well above 10m so + # Job-level ceiling. The go test step below runs with a per-step 30m timeout; + # this cap catches any step that leaks past that. Set well above 30m so # the per-step timeout is the active constraint. - timeout-minutes: 15 + timeout-minutes: 35 defaults: run: working-directory: workspace-server @@ -176,12 +176,14 @@ jobs: name: Run golangci-lint run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./... - if: always() - name: Diagnostic — per-package verbose 60s + name: Diagnostic — per-package verbose (300s timeout) run: | set +e - go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log + # 300s allows handlers + pendinguploads packages to complete on cold + # runners with -race instrumentation (~60-120s each vs ~14s non-race). + go test -race -v -timeout 300s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log handlers_exit=$? - go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log + go test -race -v -timeout 300s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log pu_exit=$? echo "::group::handlers exit=$handlers_exit (last 100 lines)" tail -100 /tmp/test-handlers.log @@ -194,10 +196,10 @@ jobs: - if: always() name: Run tests with race detection and coverage # Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the - # full ./... suite with race detection + coverage. A 10m per-step timeout - # lets the suite complete on cold cache (~5-7m) while failing cleanly - # instead of OOM-killing. The job-level timeout (15m) is a backstop. - run: go test -race -timeout 10m -coverprofile=coverage.out ./... + # full ./... suite with race detection + coverage. A 30m per-step timeout + # lets the suite complete on cold cache (~13-25m) while failing cleanly + # instead of OOM-killing. The job-level timeout (35m) is a backstop. + run: go test -race -timeout 30m -coverprofile=coverage.out ./... - if: always() name: Per-file coverage report diff --git a/canvas/src/components/__tests__/AuditTrailPanel.format.test.ts b/canvas/src/components/__tests__/AuditTrailPanel.format.test.ts new file mode 100644 index 00000000..43a47fbe --- /dev/null +++ b/canvas/src/components/__tests__/AuditTrailPanel.format.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +/** + * Tests for formatAuditRelativeTime exported from AuditTrailPanel. + */ +import { describe, it, expect } from "vitest"; +import { formatAuditRelativeTime } from "../AuditTrailPanel"; + +describe("formatAuditRelativeTime", () => { + const now = new Date("2026-05-18T12:00:00Z").getTime(); + + it('returns "just now" for timestamps less than 60s ago', () => { + const ts = new Date(now - 30_000).toISOString(); // 30s ago + expect(formatAuditRelativeTime(ts, now)).toBe("just now"); + }); + + it("returns minutes for timestamps under 1h", () => { + const ts = new Date(now - 5 * 60_000).toISOString(); // 5m ago + expect(formatAuditRelativeTime(ts, now)).toBe("5m ago"); + }); + + it("returns hours for timestamps under 24h", () => { + const ts = new Date(now - 3 * 3_600_000).toISOString(); // 3h ago + expect(formatAuditRelativeTime(ts, now)).toBe("3h ago"); + }); + + it("returns locale date for timestamps older than 24h", () => { + const ts = new Date(now - 2 * 86_400_000).toISOString(); // 2d ago + const result = formatAuditRelativeTime(ts, now); + // Returns a locale date string; just verify it's a non-empty string + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + expect(result).not.toBe("just now"); + expect(result).not.toMatch(/m ago$/); + expect(result).not.toMatch(/h ago$/); + }); + + it("handles exactly 60s boundary as minutes", () => { + const ts = new Date(now - 60_000).toISOString(); // exactly 1m ago + expect(formatAuditRelativeTime(ts, now)).toBe("1m ago"); + }); + + it("handles exactly 3600s boundary as hours", () => { + const ts = new Date(now - 3_600_000).toISOString(); // exactly 1h ago + expect(formatAuditRelativeTime(ts, now)).toBe("1h ago"); + }); + + it("handles exactly 86400s boundary", () => { + const ts = new Date(now - 86_400_000).toISOString(); // exactly 24h ago + const result = formatAuditRelativeTime(ts, now); + // Exactly 24h should fall into the "days" branch + expect(typeof result).toBe("string"); + expect(result).not.toMatch(/m ago$/); + expect(result).not.toMatch(/h ago$/); + }); +}); diff --git a/canvas/src/components/__tests__/MemoryInspectorPanel.helpers.test.ts b/canvas/src/components/__tests__/MemoryInspectorPanel.helpers.test.ts new file mode 100644 index 00000000..c2b2d935 --- /dev/null +++ b/canvas/src/components/__tests__/MemoryInspectorPanel.helpers.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +/** + * Tests for exported helpers from MemoryInspectorPanel: + * isPluginUnavailableError, formatTTL. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { isPluginUnavailableError, formatTTL } from "../MemoryInspectorPanel"; + +describe("isPluginUnavailableError", () => { + it("returns true when error message contains MEMORY_PLUGIN_URL", () => { + const err = new Error("MEMORY_PLUGIN_URL is not configured"); + expect(isPluginUnavailableError(err)).toBe(true); + }); + + it("returns false when error message does not contain MEMORY_PLUGIN_URL", () => { + const err = new Error("Connection refused"); + expect(isPluginUnavailableError(err)).toBe(false); + }); + + it("returns false for non-Error values", () => { + expect(isPluginUnavailableError("string error")).toBe(false); + expect(isPluginUnavailableError(null)).toBe(false); + expect(isPluginUnavailableError(undefined)).toBe(false); + expect(isPluginUnavailableError({})).toBe(false); + }); + + it("handles Error with empty message", () => { + expect(isPluginUnavailableError(new Error(""))).toBe(false); + }); +}); + +describe("formatTTL", () => { + // Freeze time at 2026-05-18T12:00:00Z for deterministic tests. + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-18T12:00:00Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns empty string for null", () => { + expect(formatTTL(null)).toBe(""); + }); + + it("returns empty string for undefined", () => { + expect(formatTTL(undefined)).toBe(""); + }); + + it("returns empty string for empty string", () => { + expect(formatTTL("")).toBe(""); + }); + + it("returns 'expired' for past timestamps", () => { + const past = new Date(Date.now() - 60_000).toISOString(); + expect(formatTTL(past)).toBe("expired"); + }); + + it("returns seconds for sub-minute future TTLs", () => { + const future = new Date(Date.now() + 30_000).toISOString(); + expect(formatTTL(future)).toBe("30s"); + }); + + it("returns minutes for sub-hour future TTLs", () => { + const future = new Date(Date.now() + 5 * 60_000).toISOString(); + expect(formatTTL(future)).toBe("5m"); + }); + + it("returns hours for sub-day future TTLs", () => { + const future = new Date(Date.now() + 3 * 3_600_000).toISOString(); + expect(formatTTL(future)).toBe("3h"); + }); + + it("returns days for TTLs longer than 24h", () => { + const future = new Date(Date.now() + 2 * 86_400_000).toISOString(); + expect(formatTTL(future)).toBe("2d"); + }); + + it("returns empty string for invalid date string", () => { + expect(formatTTL("not-a-date")).toBe(""); + }); +}); diff --git a/canvas/src/components/mobile/MobileChat.tsx b/canvas/src/components/mobile/MobileChat.tsx index b5940a0e..39b80268 100644 --- a/canvas/src/components/mobile/MobileChat.tsx +++ b/canvas/src/components/mobile/MobileChat.tsx @@ -242,6 +242,8 @@ export function MobileChat({ useChatSocket(agentId, { onAgentMessage: appendMessageDeduped, + // Fan-out user's own outbound message to all sessions (issue #228). + onUserMessage: appendMessageDeduped, onSendComplete: releaseSendGuards, }); @@ -748,7 +750,14 @@ export function MobileChat({ border: "none", outline: "none", background: "transparent", - fontSize: 14.5, + // 16px floor: iOS Safari/WebKit auto-zooms the viewport on + // focus when a focused field's font-size is < 16px. Anything + // below this re-introduces the tap-to-zoom layout jump on the + // mobile PWA. Do NOT lower this without also adding a + // maximum-scale/user-scalable viewport lock — and that lock + // breaks pinch-to-zoom accessibility, so 16px here is the + // correct trade. + fontSize: 16, lineHeight: 1.4, color: p.text, padding: "6px 0", diff --git a/canvas/src/components/mobile/__tests__/MobileChat.test.tsx b/canvas/src/components/mobile/__tests__/MobileChat.test.tsx index 0c8e2459..2ef96e86 100644 --- a/canvas/src/components/mobile/__tests__/MobileChat.test.tsx +++ b/canvas/src/components/mobile/__tests__/MobileChat.test.tsx @@ -263,6 +263,20 @@ describe("MobileChat — composer", () => { const sendBtn = container.querySelector('[aria-label="Send"]') as HTMLButtonElement; expect(sendBtn.disabled).toBe(true); }); + + // iOS Safari/WebKit auto-zooms the viewport on focus when a focused + // /