Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0629361ca3 |
@@ -11,87 +11,48 @@
|
||||
|
||||
## Project Context
|
||||
|
||||
Mosaic Stack is a self-hosted, multi-user AI agent platform. It is a TypeScript monorepo with a NestJS gateway, Next.js dashboard, Pi SDK agent runtime, and Discord/Telegram plugin architecture.
|
||||
Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo with NestJS gateway, Next.js web dashboard, Pi SDK agent runtime, and plugin architecture for Discord/Telegram.
|
||||
|
||||
### Stack
|
||||
## Package Map
|
||||
|
||||
- **API:** NestJS with Fastify (`apps/gateway`)
|
||||
- **Web:** Next.js 16 with React 19 (`apps/web`)
|
||||
- **ORM and database:** Drizzle ORM, PostgreSQL 17, and pgvector (`packages/db`)
|
||||
- **Authentication:** BetterAuth (`packages/auth`)
|
||||
- **Agent runtime:** Pi SDK (`apps/gateway`, `packages/mosaic`)
|
||||
- **Queue:** Valkey 8 (`packages/queue`)
|
||||
- **Build:** pnpm workspaces and Turborepo
|
||||
- **CI:** Woodpecker CI
|
||||
- **Observability:** OpenTelemetry and Jaeger
|
||||
| Package | Purpose | Key Dependencies |
|
||||
| ------------------ | ------------------------------- | -------------------------------- |
|
||||
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
||||
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
||||
| `packages/types` | Shared TypeScript contracts | class-validator |
|
||||
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
|
||||
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
||||
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
|
||||
| `packages/queue` | Valkey task queue + MCP | ioredis |
|
||||
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
||||
| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
|
||||
| `plugins/discord` | Discord channel plugin | discord.js |
|
||||
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
||||
|
||||
### Package Map
|
||||
## Architecture Rules
|
||||
|
||||
| Package | Purpose | Key Dependencies |
|
||||
| ------------------ | ----------------------------- | -------------------------------- |
|
||||
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
||||
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
||||
| `packages/types` | Shared TypeScript contracts | class-validator |
|
||||
| `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres |
|
||||
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
||||
| `packages/brain` | Structured data layer | @mosaicstack/db |
|
||||
| `packages/queue` | Valkey task queue and MCP | ioredis |
|
||||
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
||||
| `packages/mosaic` | Unified `mosaic` CLI and TUI | Ink, Pi SDK, commander |
|
||||
| `plugins/discord` | Discord channel plugin | discord.js |
|
||||
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
||||
|
||||
## Architecture and Code Conventions
|
||||
|
||||
1. Gateway is the single API surface; all clients connect through it.
|
||||
2. Pi SDK is ESM-only; gateway and CLI code must remain ESM.
|
||||
3. Use `"type": "module"`, NodeNext module resolution, and `.js` extensions in imports.
|
||||
4. Keep typed Socket.IO events in `@mosaicstack/types` to enforce client/server contracts.
|
||||
5. Import OTEL tracing before NestJS bootstrap (`import './tracing.js'`).
|
||||
6. Use explicit `@Inject()` decorators in NestJS because tsx/esbuild does not emit decorator metadata.
|
||||
7. Keep DTOs in `*.dto.ts` files at module boundaries.
|
||||
8. BetterAuth owns authentication tables; their schema is defined in `@mosaicstack/db`.
|
||||
9. Create a task-specific scratchpad for non-trivial work.
|
||||
1. Gateway is the single API surface — all clients connect through it
|
||||
2. Pi SDK is ESM-only — gateway and CLI must use ESM
|
||||
3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts
|
||||
4. OTEL auto-instrumentation loads before NestJS bootstrap
|
||||
5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db`
|
||||
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
|
||||
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
Requirements: Node.js 20+, pnpm 10.6.2, and Docker Compose when optional local services are needed.
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm preflight
|
||||
|
||||
# Optional local queue service only; do not start the full Compose stack.
|
||||
docker compose up -d valkey
|
||||
docker compose up -d # Infrastructure
|
||||
pnpm install # Dependencies
|
||||
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
||||
```
|
||||
|
||||
The pre-push hook requires:
|
||||
## Repo-Specific Notes
|
||||
|
||||
```bash
|
||||
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||
```
|
||||
|
||||
Software delivery also requires the applicable tests. Common repository commands are:
|
||||
|
||||
```bash
|
||||
pnpm typecheck # TypeScript checks across the workspace
|
||||
pnpm lint # ESLint across the workspace
|
||||
pnpm test # Checkout tests and package Vitest suites
|
||||
pnpm format:check # Prettier check
|
||||
pnpm build # Build all packages and applications
|
||||
```
|
||||
|
||||
## Database and Local Runtime Safety
|
||||
|
||||
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
||||
- PostgreSQL execution is held until KBN-101-00, KBN-101-03, and KBN-101-05 land.
|
||||
- Do not invoke a migration runner, initialization SQL, or the Compose PostgreSQL service from this checkout.
|
||||
- Do not start Gateway/Web or run root `pnpm dev` as a local PGlite route. The current dotenv loader can inherit a daemon PostgreSQL DSN; KBN-101-02 must make that path fail closed first.
|
||||
- Migration artifact generation is offline and does not authorize PostgreSQL access:
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:generate
|
||||
```
|
||||
- DTOs in `*.dto.ts` files at module boundaries
|
||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
||||
- NodeNext module resolution in all tsconfigs
|
||||
- Scratchpads are mandatory for non-trivial tasks
|
||||
|
||||
## docs/TASKS.md — Schema (CANONICAL)
|
||||
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# Claude Compatibility Pointer
|
||||
# CLAUDE.md — Mosaic Stack
|
||||
|
||||
@AGENTS.md
|
||||
## Project
|
||||
|
||||
Do not add project guidance here. Keep `AGENTS.md` authoritative so every agent runtime receives the same instructions.
|
||||
Self-hosted, multi-user AI agent platform. TypeScript monorepo.
|
||||
|
||||
## Stack
|
||||
|
||||
- **API**: NestJS + Fastify adapter (`apps/gateway`)
|
||||
- **Web**: Next.js 16 + React 19 (`apps/web`)
|
||||
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
|
||||
- **Auth**: BetterAuth (`packages/auth`)
|
||||
- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
|
||||
- **Queue**: Valkey 8 (`packages/queue`)
|
||||
- **Build**: pnpm workspaces + Turborepo
|
||||
- **CI**: Woodpecker CI
|
||||
- **Observability**: OpenTelemetry → Jaeger
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm typecheck # TypeScript check (all packages)
|
||||
pnpm lint # ESLint (all packages)
|
||||
pnpm format:check # Prettier check
|
||||
pnpm test # Vitest (all packages)
|
||||
pnpm build # Build all packages
|
||||
|
||||
# Database
|
||||
pnpm --filter @mosaicstack/db db:generate # Offline migration artifact generation only
|
||||
# PostgreSQL execution is held until KBN-101-00/-03/-05 land. Do not invoke a runner,
|
||||
# init SQL, or Compose PostgreSQL service from this checkout.
|
||||
|
||||
# Dev: local PGlite data-layer work needs no PostgreSQL. Optional local queue service only:
|
||||
docker compose up -d valkey
|
||||
# Do not start Gateway/Web or root pnpm dev as a local PGlite route: the current unguarded dotenv
|
||||
# loader can inherit a daemon PostgreSQL DSN. KBN-101-02 must make that state fail closed first.
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
||||
- NodeNext module resolution
|
||||
- Explicit `@Inject()` decorators in NestJS (tsx/esbuild doesn't support emitDecoratorMetadata)
|
||||
- DTOs in `*.dto.ts` files at module boundaries
|
||||
- OTEL tracing imported before NestJS bootstrap (`import './tracing.js'`)
|
||||
- All three gates must pass before push: typecheck, lint, format:check
|
||||
|
||||
@@ -48,13 +48,9 @@ mosaic wizard # Full guided setup (gateway install → verify)
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js ≥ 22
|
||||
- Node.js ≥ 20
|
||||
- npm (for global @mosaicstack/mosaic install)
|
||||
- One or more runtimes:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- [Codex](https://github.com/openai/codex)
|
||||
- [OpenCode](https://opencode.ai)
|
||||
- [Pi](https://pi.dev)
|
||||
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -204,7 +200,7 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js ≥ 22
|
||||
- Node.js ≥ 20
|
||||
- pnpm 10.6+
|
||||
- Docker & Docker Compose
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* to avoid real I/O — they verify the complete classify → match → decide path.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
||||
import { RoutingEngineService } from './routing-engine.service.js';
|
||||
import { DEFAULT_ROUTING_RULES } from '../routing/default-rules.js';
|
||||
import type { RoutingRule } from './routing.types.js';
|
||||
@@ -18,7 +17,7 @@ import type { RoutingRule } from './routing.types.js';
|
||||
/** Build a RoutingEngineService backed by the given rule set and health map. */
|
||||
function makeService(
|
||||
rules: RoutingRule[],
|
||||
healthMap: Record<string, { status: ProviderHealthStatus }>,
|
||||
healthMap: Record<string, { status: string }>,
|
||||
): RoutingEngineService {
|
||||
const mockDb = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
@@ -68,11 +67,11 @@ function defaultRules(): RoutingRule[] {
|
||||
}
|
||||
|
||||
/** A health map where anthropic, openai, and zai are all healthy. */
|
||||
const allHealthy: Record<string, { status: ProviderHealthStatus }> = {
|
||||
anthropic: { status: 'healthy' },
|
||||
openai: { status: 'healthy' },
|
||||
zai: { status: 'healthy' },
|
||||
ollama: { status: 'healthy' },
|
||||
const allHealthy: Record<string, { status: string }> = {
|
||||
anthropic: { status: 'up' },
|
||||
openai: { status: 'up' },
|
||||
zai: { status: 'up' },
|
||||
ollama: { status: 'up' },
|
||||
};
|
||||
|
||||
// ─── M4-013 E2E tests ─────────────────────────────────────────────────────────
|
||||
@@ -213,10 +212,10 @@ describe('M4-013: routing end-to-end pipeline', () => {
|
||||
// Let's use a simple coding message to target Simple coding → Codex (openai)
|
||||
const message = 'implement a sort function';
|
||||
|
||||
const unhealthyHealth: Record<string, { status: ProviderHealthStatus }> = {
|
||||
const unhealthyHealth = {
|
||||
anthropic: { status: 'down' },
|
||||
openai: { status: 'healthy' },
|
||||
zai: { status: 'healthy' },
|
||||
openai: { status: 'up' },
|
||||
zai: { status: 'up' },
|
||||
ollama: { status: 'down' },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { routingRules, type Db, and, asc, eq, or } from '@mosaicstack/db';
|
||||
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { ProviderService } from '../provider.service.js';
|
||||
import { classifyTask } from './task-classifier.js';
|
||||
@@ -50,7 +49,7 @@ export class RoutingEngineService {
|
||||
async resolve(
|
||||
message: string,
|
||||
userId?: string,
|
||||
availableProviders?: Record<string, { status: ProviderHealthStatus }>,
|
||||
availableProviders?: Record<string, { status: string }>,
|
||||
): Promise<RoutingDecision> {
|
||||
const classification = classifyTask(message);
|
||||
this.logger.debug(
|
||||
@@ -70,8 +69,9 @@ export class RoutingEngineService {
|
||||
if (!this.matchConditions(rule, classification)) continue;
|
||||
|
||||
const providerStatus = health[rule.action.provider]?.status;
|
||||
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
|
||||
|
||||
if (!this.isRoutable(providerStatus)) {
|
||||
if (!isHealthy) {
|
||||
this.logger.debug(
|
||||
`Rule "${rule.name}" matched but provider "${rule.action.provider}" is unhealthy (status: ${providerStatus ?? 'unknown'})`,
|
||||
);
|
||||
@@ -111,10 +111,6 @@ export class RoutingEngineService {
|
||||
|
||||
// ─── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private isRoutable(status: ProviderHealthStatus | undefined): boolean {
|
||||
return status === 'healthy' || status === 'degraded';
|
||||
}
|
||||
|
||||
private evaluateCondition(
|
||||
condition: RoutingCondition,
|
||||
classification: TaskClassification,
|
||||
@@ -190,12 +186,11 @@ export class RoutingEngineService {
|
||||
* Walk the fallback chain and return the first healthy provider/model pair.
|
||||
* If none are healthy, return the first entry unconditionally (last resort).
|
||||
*/
|
||||
private applyFallbackChain(
|
||||
health: Record<string, { status: ProviderHealthStatus }>,
|
||||
): RoutingDecision {
|
||||
private applyFallbackChain(health: Record<string, { status: string }>): RoutingDecision {
|
||||
for (const candidate of FALLBACK_CHAIN) {
|
||||
const providerStatus = health[candidate.provider]?.status;
|
||||
if (this.isRoutable(providerStatus)) {
|
||||
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
|
||||
if (isHealthy) {
|
||||
this.logger.debug(`Fallback resolved: ${candidate.provider}/${candidate.model}`);
|
||||
return {
|
||||
provider: candidate.provider,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
||||
import { RoutingEngineService } from './routing-engine.service.js';
|
||||
import type { RoutingRule, TaskClassification } from './routing.types.js';
|
||||
|
||||
@@ -30,7 +29,7 @@ function makeClassification(overrides: Partial<TaskClassification> = {}): TaskCl
|
||||
/** Build a minimal RoutingEngineService with mocked DB and ProviderService. */
|
||||
function makeService(
|
||||
rules: RoutingRule[] = [],
|
||||
healthMap: Record<string, { status: ProviderHealthStatus }> = {},
|
||||
healthMap: Record<string, { status: string }> = {},
|
||||
): RoutingEngineService {
|
||||
const mockDb = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
@@ -218,10 +217,7 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const service = makeService(rules, {
|
||||
anthropic: { status: 'healthy' },
|
||||
openai: { status: 'healthy' },
|
||||
});
|
||||
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
|
||||
|
||||
const decision = await service.resolve('implement a function');
|
||||
expect(decision.ruleName).toBe('high priority');
|
||||
@@ -245,10 +241,7 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const service = makeService(rules, {
|
||||
anthropic: { status: 'healthy' },
|
||||
openai: { status: 'healthy' },
|
||||
});
|
||||
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
|
||||
|
||||
const decision = await service.resolve('implement a function');
|
||||
expect(decision.ruleName).toBe('coding rule');
|
||||
@@ -277,7 +270,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
||||
|
||||
const service = makeService(rules, {
|
||||
anthropic: { status: 'down' }, // primary is unhealthy
|
||||
openai: { status: 'healthy' },
|
||||
openai: { status: 'up' },
|
||||
});
|
||||
|
||||
const decision = await service.resolve('implement a function');
|
||||
@@ -297,7 +290,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
||||
];
|
||||
|
||||
const service2 = makeService(unhealthyRules, {
|
||||
anthropic: { status: 'healthy' },
|
||||
anthropic: { status: 'up' },
|
||||
openai: { status: 'down' },
|
||||
});
|
||||
|
||||
@@ -313,7 +306,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
||||
|
||||
const service = makeService(rules, {
|
||||
anthropic: { status: 'down' }, // Sonnet is on anthropic — down
|
||||
ollama: { status: 'healthy' }, // Haiku is also on anthropic — use Ollama as next
|
||||
ollama: { status: 'up' }, // Haiku is also on anthropic — use Ollama as next
|
||||
});
|
||||
|
||||
const decision = await service.resolve('hello there');
|
||||
@@ -352,7 +345,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
|
||||
}),
|
||||
];
|
||||
|
||||
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
||||
const service = makeService(rules, { anthropic: { status: 'up' } });
|
||||
|
||||
const decision = await service.resolve('completely unrelated message xyz');
|
||||
expect(decision.ruleName).toBe('catch-all');
|
||||
@@ -376,7 +369,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
|
||||
}),
|
||||
];
|
||||
|
||||
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
||||
const service = makeService(rules, { anthropic: { status: 'up' } });
|
||||
|
||||
const codingDecision = await service.resolve('implement a function');
|
||||
expect(codingDecision.ruleName).toBe('specific coding rule');
|
||||
@@ -408,7 +401,7 @@ describe('RoutingEngineService.resolve — disabled rules', () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
||||
const service = makeService(rules, { anthropic: { status: 'up' } });
|
||||
|
||||
const decision = await service.resolve('implement a function');
|
||||
expect(decision.ruleName).toBe('enabled fallback');
|
||||
@@ -459,45 +452,9 @@ describe('RoutingEngineService.resolve — availableProviders override', () => {
|
||||
ps: unknown,
|
||||
) => RoutingEngineService)(mockDb, mockProviderService);
|
||||
|
||||
const preSupplied: Record<string, { status: ProviderHealthStatus }> = {
|
||||
anthropic: { status: 'healthy' },
|
||||
};
|
||||
const preSupplied = { anthropic: { status: 'up' } };
|
||||
await service.resolve('implement a function', undefined, preSupplied);
|
||||
|
||||
expect(mockHealthCheckAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolve — canonical ProviderHealthStatus values ──────────────────────────
|
||||
|
||||
describe('RoutingEngineService.resolve — canonical health status routing', () => {
|
||||
it('routes healthy and degraded providers by rule, and falls through to fallback when down', async () => {
|
||||
const codingRule = makeRule({
|
||||
name: 'coding rule',
|
||||
priority: 1,
|
||||
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
||||
action: { provider: 'openai', model: 'gpt-4o' },
|
||||
});
|
||||
|
||||
// healthy → selected by its own rule, not the fallback chain
|
||||
const healthyService = makeService([codingRule], { openai: { status: 'healthy' } });
|
||||
const healthyDecision = await healthyService.resolve('implement a function');
|
||||
expect(healthyDecision.ruleName).toBe('coding rule');
|
||||
expect(healthyDecision.provider).toBe('openai');
|
||||
|
||||
// down → rule is skipped as unroutable, falls through to the fallback chain
|
||||
const downService = makeService([codingRule], {
|
||||
openai: { status: 'down' },
|
||||
anthropic: { status: 'healthy' },
|
||||
});
|
||||
const downDecision = await downService.resolve('implement a function');
|
||||
expect(downDecision.ruleName).toBe('fallback');
|
||||
expect(downDecision.provider).toBe('anthropic');
|
||||
|
||||
// degraded → still routable, selected by its own rule, not the fallback chain
|
||||
const degradedService = makeService([codingRule], { openai: { status: 'degraded' } });
|
||||
const degradedDecision = await degradedService.resolve('implement a function');
|
||||
expect(degradedDecision.ruleName).toBe('coding rule');
|
||||
expect(degradedDecision.provider).toBe('openai');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,624 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import * as nodeOs from 'node:os';
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import * as nodeUrl from 'node:url';
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants.js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
|
||||
interface ComposedModuleGraph {
|
||||
imports: readonly unknown[];
|
||||
federationModule: unknown;
|
||||
bootLogLines: readonly string[];
|
||||
mosaicConfig: MosaicConfig;
|
||||
resolvedConfigPath: string;
|
||||
}
|
||||
|
||||
type StorageTier = 'local' | 'standalone' | 'federated';
|
||||
|
||||
interface ModuleGraphFixture {
|
||||
tempRoot: string;
|
||||
anchor: string;
|
||||
homePath: string;
|
||||
cwdPath: string;
|
||||
monorepoRootEnvPath: string;
|
||||
gatewayLocalEnvPath: string;
|
||||
daemonEnvPath: string;
|
||||
monorepoRootConfigPath: string;
|
||||
gatewayLocalConfigPath: string;
|
||||
}
|
||||
|
||||
interface ModuleGraphFixtureOptions {
|
||||
rootEnvMode?: 'present' | 'absent';
|
||||
rootTier?: StorageTier;
|
||||
rootEnvContents?: string;
|
||||
redactionMarker?: string;
|
||||
gatewayLocalTier?: StorageTier;
|
||||
gatewayLocalEnvContents?: string;
|
||||
daemonEnvContents?: string;
|
||||
inheritedTier?: StorageTier;
|
||||
expectedProcessTier?: string;
|
||||
setup?: (fixture: ModuleGraphFixture) => Promise<void>;
|
||||
}
|
||||
|
||||
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
||||
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
||||
const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env';
|
||||
const DAEMON_DOTENV_LABEL = 'daemon .env';
|
||||
|
||||
function configJson(tier: StorageTier): string {
|
||||
if (tier === 'local') {
|
||||
return JSON.stringify({
|
||||
tier,
|
||||
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
||||
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
||||
memory: { type: 'keyword' },
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
tier,
|
||||
storage: { type: 'postgres', url: 'postgresql://fixture.invalid/mosaic' },
|
||||
queue: { type: 'bullmq' },
|
||||
memory: { type: tier === 'federated' ? 'pgvector' : 'keyword' },
|
||||
});
|
||||
}
|
||||
|
||||
function snapshotProcessEnv(): Record<string, string | undefined> {
|
||||
return { ...process.env };
|
||||
}
|
||||
|
||||
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in snapshot)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
||||
const relativePath = relative(tempRoot, path);
|
||||
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
||||
expectPathUnderTempRoot(path, tempRoot);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, contents, 'utf8');
|
||||
}
|
||||
|
||||
interface ConfigModuleProvider {
|
||||
provide: string;
|
||||
useFactory: () => MosaicConfig;
|
||||
}
|
||||
|
||||
function isConfigModuleProvider(value: unknown): value is ConfigModuleProvider {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('provide' in value) || typeof value.provide !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'useFactory' in value && typeof value.useFactory === 'function';
|
||||
}
|
||||
|
||||
function singleBootLogLine(bootLogLines: readonly string[]): string {
|
||||
expect(bootLogLines).toHaveLength(1);
|
||||
const [bootLogLine] = bootLogLines;
|
||||
if (bootLogLine === undefined) {
|
||||
throw new Error('Expected a single boot log line');
|
||||
}
|
||||
|
||||
return bootLogLine;
|
||||
}
|
||||
|
||||
function expectBootLogLine(
|
||||
bootLogLines: readonly string[],
|
||||
tier: StorageTier,
|
||||
source: string,
|
||||
): void {
|
||||
const bootLogLine = singleBootLogLine(bootLogLines);
|
||||
|
||||
expect(bootLogLine).toContain(`storage tier=${tier}`);
|
||||
expect(bootLogLine).toContain(`source=${source}`);
|
||||
}
|
||||
|
||||
async function loadModuleGraphFromDotenv(
|
||||
options: ModuleGraphFixtureOptions,
|
||||
): Promise<ComposedModuleGraph> {
|
||||
const originalEnv = snapshotProcessEnv();
|
||||
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-module-'));
|
||||
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
|
||||
try {
|
||||
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
||||
const homePath = join(tempRoot, 'home');
|
||||
const cwdPath = join(tempRoot, 'ambient', 'parent', 'cwd');
|
||||
const fixture: ModuleGraphFixture = {
|
||||
tempRoot,
|
||||
anchor,
|
||||
homePath,
|
||||
cwdPath,
|
||||
monorepoRootEnvPath: resolve(anchor, '../../..', '.env'),
|
||||
gatewayLocalEnvPath: resolve(anchor, '..', '.env'),
|
||||
daemonEnvPath: join(homePath, '.config', 'mosaic', 'gateway', '.env'),
|
||||
monorepoRootConfigPath: resolve(anchor, '../../..', 'mosaic.config.json'),
|
||||
gatewayLocalConfigPath: resolve(anchor, '..', 'mosaic.config.json'),
|
||||
};
|
||||
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
||||
|
||||
for (const path of Object.values(fixture)) {
|
||||
expectPathUnderTempRoot(path, tempRoot);
|
||||
}
|
||||
|
||||
await mkdir(anchor, { recursive: true });
|
||||
await mkdir(cwdPath, { recursive: true });
|
||||
|
||||
if ((options.rootEnvMode ?? 'present') === 'absent') {
|
||||
if (
|
||||
options.rootEnvContents !== undefined ||
|
||||
options.rootTier !== undefined ||
|
||||
options.redactionMarker !== undefined
|
||||
) {
|
||||
throw new Error('Expected no root env fixture values when rootEnvMode is absent');
|
||||
}
|
||||
} else {
|
||||
if (options.rootEnvContents === undefined && options.rootTier === undefined) {
|
||||
throw new Error('Expected rootTier or rootEnvContents');
|
||||
}
|
||||
|
||||
const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`;
|
||||
const rootFixtureWithMarker = options.redactionMarker
|
||||
? `${rootFixture}BETTER_AUTH_SECRET=${options.redactionMarker}\n`
|
||||
: rootFixture;
|
||||
await writeFixture(fixture.monorepoRootEnvPath, rootFixtureWithMarker, tempRoot);
|
||||
}
|
||||
|
||||
if (options.daemonEnvContents !== undefined) {
|
||||
await writeFixture(fixture.daemonEnvPath, options.daemonEnvContents, tempRoot);
|
||||
}
|
||||
|
||||
if (options.gatewayLocalEnvContents !== undefined) {
|
||||
await writeFixture(fixture.gatewayLocalEnvPath, options.gatewayLocalEnvContents, tempRoot);
|
||||
} else if (options.gatewayLocalTier !== undefined) {
|
||||
await writeFixture(
|
||||
fixture.gatewayLocalEnvPath,
|
||||
`MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`,
|
||||
tempRoot,
|
||||
);
|
||||
}
|
||||
|
||||
process.env['HOME'] = homePath;
|
||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||
delete process.env['DATABASE_URL'];
|
||||
delete process.env['VALKEY_URL'];
|
||||
delete process.env['MOSAIC_GATEWAY_HOME'];
|
||||
|
||||
await options.setup?.(fixture);
|
||||
|
||||
if (options.inheritedTier !== undefined) {
|
||||
process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier;
|
||||
}
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
||||
vi.doMock('node:url', () => ({
|
||||
...nodeUrl,
|
||||
fileURLToPath: (url: string | URL): string => {
|
||||
const actualPath = nodeUrl.fileURLToPath(url);
|
||||
if (
|
||||
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
||||
actualPath.endsWith('/apps/gateway/src/env.js')
|
||||
) {
|
||||
return join(anchor, 'env.ts');
|
||||
}
|
||||
return actualPath;
|
||||
},
|
||||
}));
|
||||
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
||||
|
||||
if (options.inheritedTier === undefined) {
|
||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
|
||||
} else {
|
||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier);
|
||||
}
|
||||
|
||||
const envModule = await import('./env.js');
|
||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(
|
||||
options.expectedProcessTier ?? options.rootTier,
|
||||
);
|
||||
|
||||
const { AppModule } = await import('./app.module.js');
|
||||
const { FederationModule } = await import('./federation/federation.module.js');
|
||||
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule);
|
||||
|
||||
if (!Array.isArray(imports)) {
|
||||
throw new Error('AppModule imports metadata is not an array');
|
||||
}
|
||||
|
||||
const { ConfigModule, MOSAIC_CONFIG } = await import('./config/config.module.js');
|
||||
const providers: unknown = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, ConfigModule);
|
||||
|
||||
if (!Array.isArray(providers)) {
|
||||
throw new Error('ConfigModule providers metadata is not an array');
|
||||
}
|
||||
|
||||
const configProvider = providers
|
||||
.filter(isConfigModuleProvider)
|
||||
.find((provider: ConfigModuleProvider): boolean => provider.provide === MOSAIC_CONFIG);
|
||||
|
||||
if (!configProvider) {
|
||||
throw new Error('MOSAIC_CONFIG provider factory not found');
|
||||
}
|
||||
|
||||
return {
|
||||
imports,
|
||||
federationModule: FederationModule,
|
||||
bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string =>
|
||||
args.map((value: unknown): string => String(value)).join(' '),
|
||||
),
|
||||
mosaicConfig: configProvider.useFactory(),
|
||||
resolvedConfigPath: envModule.resolveGatewayConfigPath(),
|
||||
};
|
||||
} finally {
|
||||
cwdSpy?.mockRestore();
|
||||
vi.doUnmock('node:url');
|
||||
vi.doUnmock('node:os');
|
||||
vi.resetModules();
|
||||
consoleInfoSpy?.mockRestore();
|
||||
restoreProcessEnv(originalEnv);
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('AppModule federation gating', (): void => {
|
||||
it('loads dotenv before tracing and AppModule evaluation', async (): Promise<void> => {
|
||||
const mainSource = await readFile(new URL('./main.ts', import.meta.url), 'utf8');
|
||||
const envImportIndex = mainSource.indexOf("import './env.js';");
|
||||
const tracingImportIndex = mainSource.indexOf("import './tracing.js';");
|
||||
const appModuleImportIndex = mainSource.indexOf("import { AppModule } from './app.module.js';");
|
||||
|
||||
expect(envImportIndex).toBeGreaterThan(-1);
|
||||
expect(envImportIndex).toBeLessThan(tracingImportIndex);
|
||||
expect(envImportIndex).toBeLessThan(appModuleImportIndex);
|
||||
});
|
||||
|
||||
it(
|
||||
'ignores ambient cwd/.env and cwd/../.env files',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
await writeFixture(
|
||||
join(fixture.cwdPath, '.env'),
|
||||
'MOSAIC_STORAGE_TIER=federated\n',
|
||||
fixture.tempRoot,
|
||||
);
|
||||
await writeFixture(
|
||||
resolve(fixture.cwdPath, '..', '.env'),
|
||||
'MOSAIC_STORAGE_TIER=federated\n',
|
||||
fixture.tempRoot,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'ignores an ambient cwd/mosaic.config.json federated config',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
await writeFixture(
|
||||
join(fixture.cwdPath, 'mosaic.config.json'),
|
||||
configJson('federated'),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'ignores an ambient cwd/../../mosaic.config.json federated config',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
await writeFixture(
|
||||
resolve(fixture.cwdPath, '../..', 'mosaic.config.json'),
|
||||
configJson('federated'),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'anchored gateway-local config wins monorepo-root config and registers FederationModule',
|
||||
async (): Promise<void> => {
|
||||
let gatewayLocalConfigPath = '';
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
gatewayLocalConfigPath = fixture.gatewayLocalConfigPath;
|
||||
await writeFixture(
|
||||
fixture.gatewayLocalConfigPath,
|
||||
configJson('federated'),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.resolvedConfigPath).toBe(gatewayLocalConfigPath);
|
||||
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||
expect(graph.imports).toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'resolves the daemon-installed GATEWAY_HOME/mosaic.config.json ahead of gateway-local and monorepo-root configs',
|
||||
async (): Promise<void> => {
|
||||
let daemonConfigPath = '';
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootEnvMode: 'absent',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
const externalGatewayHome = join(fixture.tempRoot, 'external-gateway-home');
|
||||
daemonConfigPath = join(externalGatewayHome, 'mosaic.config.json');
|
||||
await writeFixture(daemonConfigPath, configJson('federated'), fixture.tempRoot);
|
||||
await writeFixture(
|
||||
fixture.gatewayLocalConfigPath,
|
||||
configJson('standalone'),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
||||
process.env['MOSAIC_GATEWAY_HOME'] = externalGatewayHome;
|
||||
process.env['DATABASE_URL'] = 'postgresql://fixture.invalid/mosaic';
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.resolvedConfigPath).toBe(daemonConfigPath);
|
||||
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||
expect(graph.imports).toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'logs mosaic.config.json when anchored config and env tiers are both federated',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'federated',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
await writeFixture(
|
||||
fixture.monorepoRootConfigPath,
|
||||
configJson('federated'),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.imports).toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'logs standalone from a monorepo-root .env DATABASE_URL fallback',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'attributes an invalid monorepo-root dotenv tier to the default',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
|
||||
expectedProcessTier: 'invalid',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'local', 'default');
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'attributes DATABASE_URL fallback to daemon .env ahead of inherited local tier',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootEnvMode: 'absent',
|
||||
daemonEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
||||
inheritedTier: 'local',
|
||||
expectedProcessTier: 'local',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'daemon .env wins over monorepo-root and gateway-local tier values',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
gatewayLocalTier: 'federated',
|
||||
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
|
||||
expectedProcessTier: 'standalone',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
gatewayLocalTier: 'federated',
|
||||
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
|
||||
inheritedTier: 'standalone',
|
||||
expectedProcessTier: 'standalone',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'gateway-local .env configures the tier and source when the monorepo-root .env is absent',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootEnvMode: 'absent',
|
||||
gatewayLocalTier: 'federated',
|
||||
expectedProcessTier: 'federated',
|
||||
});
|
||||
|
||||
expect(graph.imports).toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'monorepo-root .env wins over gateway-local tier values',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'standalone',
|
||||
gatewayLocalTier: 'federated',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it.each(['local', 'standalone'] as const)(
|
||||
'does not register FederationModule for the %s tier',
|
||||
async (tier): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({ rootTier: tier });
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'registers FederationModule when federated tier is supplied by the anchored monorepo root .env',
|
||||
async (): Promise<void> => {
|
||||
const redactionMarker = 'redaction-fixture-marker';
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'federated',
|
||||
redactionMarker,
|
||||
});
|
||||
|
||||
expect(graph.imports).toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
expect(singleBootLogLine(graph.bootLogLines)).not.toContain(redactionMarker);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'MOSAIC_CONFIG provider ignores an ambient cwd/mosaic.config.json config',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
await writeFixture(
|
||||
join(fixture.cwdPath, 'mosaic.config.json'),
|
||||
JSON.stringify({
|
||||
tier: 'federated',
|
||||
storage: {
|
||||
type: 'postgres',
|
||||
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
||||
enableVector: true,
|
||||
},
|
||||
queue: { type: 'bullmq' },
|
||||
memory: { type: 'pgvector' },
|
||||
}),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.mosaicConfig.tier).toBe('local');
|
||||
expect(graph.mosaicConfig.storage).not.toEqual(
|
||||
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
||||
);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'MOSAIC_CONFIG provider resolves from the anchored monorepo-root mosaic.config.json',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
await writeFixture(
|
||||
fixture.monorepoRootConfigPath,
|
||||
configJson('federated'),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||
expect(graph.mosaicConfig.storage).toEqual(
|
||||
expect.objectContaining({ url: 'postgresql://fixture.invalid/mosaic' }),
|
||||
);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
@@ -21,22 +21,11 @@ import { AdminModule } from './admin/admin.module.js';
|
||||
import { CommandsModule } from './commands/commands.module.js';
|
||||
import { PreferencesModule } from './preferences/preferences.module.js';
|
||||
import { GCModule } from './gc/gc.module.js';
|
||||
import { HarnessModule } from './harness/harness.module.js';
|
||||
import { ReloadModule } from './reload/reload.module.js';
|
||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||
import { QueueModule } from './queue/queue.module.js';
|
||||
import { FederationModule } from './federation/federation.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { loadConfig } from '@mosaicstack/config';
|
||||
import { resolveGatewayConfigPath } from './env.js';
|
||||
|
||||
// Federation (step-ca client, enrollment, federation verbs) is only wired for
|
||||
// tier 'federated' — CaService hard-requires STEP_CA_* at construction, which
|
||||
// must not gate standalone/local boots (docker-compose.federated.yml: the
|
||||
// federation profile "must not start in non-federated dev"). The gateway
|
||||
// entrypoint loads env.ts before evaluating this module so dotenv-backed tier
|
||||
// configuration is visible here.
|
||||
const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'federated';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -61,11 +50,10 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
||||
PreferencesModule,
|
||||
CommandsModule,
|
||||
GCModule,
|
||||
HarnessModule,
|
||||
QueueModule,
|
||||
ReloadModule,
|
||||
WorkspaceModule,
|
||||
...(federationEnabled ? [FederationModule] : []),
|
||||
FederationModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||
@@ -13,7 +12,6 @@ const mockRegistry = {
|
||||
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
||||
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||
{ name: 'mcp', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||
],
|
||||
skills: [],
|
||||
})),
|
||||
@@ -74,20 +72,7 @@ const mockChatGateway = {
|
||||
broadcastSessionInfo: vi.fn(),
|
||||
};
|
||||
|
||||
const mockMcpClient = {
|
||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||
getServerStatuses: vi.fn(() => []),
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
};
|
||||
|
||||
function buildService(
|
||||
redis: typeof mockRedis | null = mockRedis,
|
||||
mcpClient: {
|
||||
reconnectServer: ReturnType<typeof vi.fn>;
|
||||
getServerStatuses: ReturnType<typeof vi.fn>;
|
||||
getToolDefinitions: ReturnType<typeof vi.fn>;
|
||||
} = mockMcpClient,
|
||||
): CommandExecutorService {
|
||||
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
mockRegistry as never,
|
||||
mockAgentService as never,
|
||||
@@ -97,7 +82,7 @@ function buildService(
|
||||
mockBrain as never,
|
||||
null,
|
||||
mockChatGateway as never,
|
||||
mcpClient as never,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -273,124 +258,4 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
expect(result.command).toBe('tools');
|
||||
expect(result.message).toContain('tools');
|
||||
});
|
||||
|
||||
// Top-level catch sanitization (P3-4 re-review finding #1): a rejected
|
||||
// Redis `set` inside /provider login is the only reachable path into the
|
||||
// top-level catch in `execute()`. The raw exception must be logged
|
||||
// server-side but never handed back to the socket client.
|
||||
it('sanitizes the top-level command catch, logging the raw exception but never returning it to the client', async () => {
|
||||
const distinctiveRawFailure = 'ECONNREFUSED distinctive-raw-redis-failure-token-9f31';
|
||||
const rawError = new Error(distinctiveRawFailure);
|
||||
const failingRedis = {
|
||||
set: vi.fn().mockRejectedValue(rawError),
|
||||
get: vi.fn(),
|
||||
del: vi.fn(),
|
||||
};
|
||||
const failingService = buildService(failingRedis as unknown as typeof mockRedis);
|
||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'provider',
|
||||
args: 'login anthropic',
|
||||
conversationId,
|
||||
};
|
||||
const result = await failingService.execute(payload, userScope);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.command).toBe('provider');
|
||||
expect(result.message).toBe('Command failed due to an internal error.');
|
||||
expect(result.message).not.toContain(distinctiveRawFailure);
|
||||
expect(result.message).not.toContain('ECONNREFUSED');
|
||||
|
||||
// The real exception is still logged server-side, as the raw Error
|
||||
// object itself (not stringified/interpolated into the log message).
|
||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(rawError));
|
||||
expect(loggedRawError).toBe(true);
|
||||
|
||||
loggerErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
// Inner catch sanitization (P3-5 operator ruling): every catch in
|
||||
// command-executor.service.ts that returns a SlashCommandResultPayload
|
||||
// must sanitize the client-facing message the same way the top-level
|
||||
// catch does, while still logging the raw exception server-side.
|
||||
it('/agent new sanitizes agent-creation failures, logging the raw exception but never returning it to the client', async () => {
|
||||
const marker = new Error('distinctive-agent-create-failure-token-A17f');
|
||||
mockBrain.agents.create.mockRejectedValueOnce(marker);
|
||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'agent',
|
||||
args: 'new my-new-agent',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userScope);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.command).toBe('agent');
|
||||
expect(result.message).toBe('Failed to create agent due to an internal error.');
|
||||
expect(result.message).not.toContain('distinctive-agent-create-failure-token-A17f');
|
||||
|
||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
||||
expect(loggedRawError).toBe(true);
|
||||
|
||||
loggerErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('/agent <name> switch sanitizes agent-lookup failures, logging the raw exception but never returning it to the client', async () => {
|
||||
const marker = new Error('distinctive-agent-switch-failure-token-B29c');
|
||||
mockBrain.agents.findByName.mockRejectedValueOnce(marker);
|
||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'agent',
|
||||
args: 'some-other-agent',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userScope);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.command).toBe('agent');
|
||||
expect(result.message).toBe('Failed to switch agent due to an internal error.');
|
||||
expect(result.message).not.toContain('distinctive-agent-switch-failure-token-B29c');
|
||||
|
||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
||||
expect(loggedRawError).toBe(true);
|
||||
|
||||
loggerErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('/mcp reconnect sanitizes MCP client failures, logging the raw exception but never returning it to the client', async () => {
|
||||
const marker = new Error('distinctive-mcp-reconnect-failure-token-C33e');
|
||||
const mockMcpClient = {
|
||||
reconnectServer: vi.fn().mockRejectedValue(marker),
|
||||
getServerStatuses: vi.fn(() => []),
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
};
|
||||
const mcpService = buildService(mockRedis, mockMcpClient);
|
||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'mcp',
|
||||
args: 'reconnect my-server',
|
||||
conversationId,
|
||||
};
|
||||
const result = await mcpService.execute(payload, userScope);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.command).toBe('mcp');
|
||||
expect(result.message).toBe(
|
||||
'Failed to reconnect MCP server "my-server" due to an internal error.',
|
||||
);
|
||||
expect(result.message).not.toContain('distinctive-mcp-reconnect-failure-token-C33e');
|
||||
|
||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
||||
expect(loggedRawError).toBe(true);
|
||||
|
||||
loggerErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,12 +36,6 @@ const authorization = {
|
||||
),
|
||||
};
|
||||
|
||||
const mockMcpClient = {
|
||||
getServerStatuses: vi.fn(() => []),
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
registry as never,
|
||||
@@ -52,7 +46,7 @@ function buildExecutor(authorizationService: unknown = authorization): CommandEx
|
||||
{ agents: {} } as never,
|
||||
null,
|
||||
null,
|
||||
mockMcpClient as never,
|
||||
null,
|
||||
authorizationService as never,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ export class CommandExecutorService {
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ChatGateway))
|
||||
private readonly chatGateway: ChatGateway | null,
|
||||
@Inject(McpClientService) private readonly mcpClient: McpClientService,
|
||||
@Optional()
|
||||
@Inject(McpClientService)
|
||||
private readonly mcpClient: McpClientService | null,
|
||||
@Optional()
|
||||
@Inject(CommandAuthorizationService)
|
||||
private readonly authorization: CommandAuthorizationService | null = null,
|
||||
@@ -157,13 +159,8 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Command /${command} failed`, err);
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: false,
|
||||
message: 'Command failed due to an internal error.',
|
||||
};
|
||||
this.logger.error(`Command /${command} failed: ${err}`);
|
||||
return { command, conversationId, success: false, message: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,11 +336,11 @@ export class CommandExecutorService {
|
||||
data: { agentId: newAgent.id, agentName: newAgent.name },
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to create agent "${namePart}" for user ${userId}`, err);
|
||||
this.logger.error(`Failed to create agent: ${err}`);
|
||||
return {
|
||||
command: 'agent',
|
||||
success: false,
|
||||
message: 'Failed to create agent due to an internal error.',
|
||||
message: `Failed to create agent: ${String(err)}`,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
@@ -394,11 +391,11 @@ export class CommandExecutorService {
|
||||
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to switch agent "${agentName}"`, err);
|
||||
this.logger.error(`Failed to switch agent "${agentName}": ${err}`);
|
||||
return {
|
||||
command: 'agent',
|
||||
success: false,
|
||||
message: 'Failed to switch agent due to an internal error.',
|
||||
message: `Failed to switch agent: ${String(err)}`,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
@@ -546,6 +543,15 @@ export class CommandExecutorService {
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!this.mcpClient) {
|
||||
return {
|
||||
command: 'mcp',
|
||||
conversationId,
|
||||
success: false,
|
||||
message: 'MCP client service is not available.',
|
||||
};
|
||||
}
|
||||
|
||||
const action = args?.trim().split(/\s+/)[0] ?? 'status';
|
||||
|
||||
switch (action) {
|
||||
@@ -602,12 +608,11 @@ export class CommandExecutorService {
|
||||
message: `MCP server "${serverName}" reconnected successfully.`,
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to reconnect MCP server "${serverName}"`, err);
|
||||
return {
|
||||
command: 'mcp',
|
||||
conversationId,
|
||||
success: false,
|
||||
message: `Failed to reconnect MCP server "${serverName}" due to an internal error.`,
|
||||
message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
* - Unknown command returns descriptive error
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CommandsModule } from './commands.module.js';
|
||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||
@@ -49,12 +47,6 @@ const mockBrain = {
|
||||
},
|
||||
};
|
||||
|
||||
const mockMcpClient = {
|
||||
getServerStatuses: vi.fn(() => []),
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildRegistry(): CommandRegistryService {
|
||||
@@ -73,7 +65,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
|
||||
mockBrain as never,
|
||||
null, // reloadService (optional)
|
||||
null, // chatGateway (optional)
|
||||
mockMcpClient as never,
|
||||
null, // mcpClient (optional)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,15 +153,6 @@ describe('CommandRegistryService — integration', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Module Wiring Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('CommandsModule — Nest wiring', () => {
|
||||
it('CommandsModule imports McpClientModule in its Nest metadata', () => {
|
||||
const imports = Reflect.getMetadata('imports', CommandsModule) ?? [];
|
||||
expect(imports).toContain(McpClientModule);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Executor Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('CommandExecutorService — integration', () => {
|
||||
@@ -276,14 +259,4 @@ describe('CommandExecutorService — integration', () => {
|
||||
expect(result.command).toBe(cmd);
|
||||
});
|
||||
}
|
||||
|
||||
// /mcp status reaches the required McpClientService and never reports it unavailable
|
||||
it('/mcp status calls the wired McpClientService and reports the no-servers message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'mcp', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(mockMcpClient.getServerStatuses).toHaveBeenCalledOnce();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('No MCP servers configured.');
|
||||
expect(result.message).not.toBe('MCP client service is not available.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { ChatModule } from '../chat/chat.module.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||
import { ReloadModule } from '../reload/reload.module.js';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
@@ -15,12 +14,7 @@ import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
GCModule,
|
||||
McpClientModule,
|
||||
forwardRef(() => ReloadModule),
|
||||
forwardRef(() => ChatModule),
|
||||
],
|
||||
imports: [GCModule, forwardRef(() => ReloadModule), forwardRef(() => ChatModule)],
|
||||
providers: [
|
||||
{
|
||||
provide: COMMANDS_QUEUE_HANDLE,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
||||
import { resolveGatewayConfigPath } from '../env.js';
|
||||
|
||||
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
||||
|
||||
@@ -9,7 +8,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
||||
providers: [
|
||||
{
|
||||
provide: MOSAIC_CONFIG,
|
||||
useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()),
|
||||
useFactory: (): MosaicConfig => loadConfig(),
|
||||
},
|
||||
],
|
||||
exports: [MOSAIC_CONFIG],
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CoordModule } from './coord.module.js';
|
||||
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
|
||||
describe('CoordModule DI (compiled-metadata boot)', () => {
|
||||
it('resolves InteractionCoordinationService through Nest DI', async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [CoordModule] })
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: (): boolean => true })
|
||||
.compile();
|
||||
expect(moduleRef.get(InteractionCoordinationService)).toBeInstanceOf(
|
||||
InteractionCoordinationService,
|
||||
);
|
||||
await moduleRef.close();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
InteractionCoordinationClient,
|
||||
type CoordinationObservation,
|
||||
@@ -13,7 +13,6 @@ import type { CreateHandoffDto } from './interaction-coordination.dto.js';
|
||||
|
||||
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
||||
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
||||
export const HANDOFF_ID_FACTORY = Symbol('HANDOFF_ID_FACTORY');
|
||||
|
||||
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||
const MAX_TRACKED_HANDOFFS = 1_000;
|
||||
@@ -61,8 +60,6 @@ export class InteractionCoordinationService {
|
||||
constructor(
|
||||
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
||||
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
||||
@Optional()
|
||||
@Inject(HANDOFF_ID_FACTORY)
|
||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||
) {}
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { config } from 'dotenv';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { detectFromEnv, loadConfig } from '@mosaicstack/config';
|
||||
|
||||
type TierSource =
|
||||
| 'process environment'
|
||||
| 'daemon .env'
|
||||
| 'monorepo-root .env'
|
||||
| 'gateway-local .env'
|
||||
| 'default';
|
||||
|
||||
type BootSource = TierSource | 'mosaic.config.json';
|
||||
|
||||
export interface GatewayDotenvPaths {
|
||||
daemonEnv: string;
|
||||
monorepoRootEnv: string;
|
||||
gatewayLocalEnv: string;
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export function resolveGatewayDotenvPaths(
|
||||
anchor: string = here,
|
||||
homeBase: string = homedir(),
|
||||
): GatewayDotenvPaths {
|
||||
return {
|
||||
daemonEnv: join(homeBase, '.config', 'mosaic', 'gateway', '.env'),
|
||||
monorepoRootEnv: resolve(anchor, '../../..', '.env'),
|
||||
gatewayLocalEnv: resolve(anchor, '..', '.env'),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGatewayConfigPath(anchor: string = here): string {
|
||||
// GATEWAY_HOME is daemon-created 0700; its env override adds no authority because env can set MOSAIC_STORAGE_TIER.
|
||||
const gatewayHome = resolve(
|
||||
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
|
||||
);
|
||||
const daemonConfig = join(gatewayHome, 'mosaic.config.json');
|
||||
const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json');
|
||||
const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json');
|
||||
|
||||
if (existsSync(daemonConfig)) {
|
||||
return daemonConfig;
|
||||
}
|
||||
if (existsSync(gatewayLocalConfig)) {
|
||||
return gatewayLocalConfig;
|
||||
}
|
||||
if (existsSync(monorepoRootConfig)) {
|
||||
return monorepoRootConfig;
|
||||
}
|
||||
|
||||
return monorepoRootConfig;
|
||||
}
|
||||
|
||||
export function loadGatewayEnv(anchor: string = here, homeBase: string = homedir()): void {
|
||||
const { daemonEnv, monorepoRootEnv, gatewayLocalEnv } = resolveGatewayDotenvPaths(
|
||||
anchor,
|
||||
homeBase,
|
||||
);
|
||||
const inheritedTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||
let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment';
|
||||
const inheritedDatabaseUrl = process.env['DATABASE_URL'];
|
||||
let databaseUrlSource: TierSource =
|
||||
inheritedDatabaseUrl === undefined ? 'default' : 'process environment';
|
||||
|
||||
function loadAnchoredDotenv(
|
||||
path: string,
|
||||
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
|
||||
): void {
|
||||
if (!existsSync(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const beforeTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||
const beforeDatabaseUrl = process.env['DATABASE_URL'];
|
||||
config({ path, quiet: true });
|
||||
|
||||
if (
|
||||
beforeTier === undefined &&
|
||||
process.env['MOSAIC_STORAGE_TIER'] !== undefined &&
|
||||
tierSource === 'default'
|
||||
) {
|
||||
tierSource = sourceLabel;
|
||||
}
|
||||
|
||||
if (
|
||||
beforeDatabaseUrl === undefined &&
|
||||
process.env['DATABASE_URL'] !== undefined &&
|
||||
databaseUrlSource === 'default'
|
||||
) {
|
||||
databaseUrlSource = sourceLabel;
|
||||
}
|
||||
}
|
||||
|
||||
// Load .env from daemon config dir (global install / daemon mode) first.
|
||||
// It takes precedence over file-based local-dev configuration.
|
||||
loadAnchoredDotenv(daemonEnv, 'daemon .env');
|
||||
|
||||
// Load .env from the anchored monorepo root, then fill any remaining values
|
||||
// from apps/gateway/.env when present.
|
||||
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
|
||||
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
|
||||
|
||||
const envOnlyTier = detectFromEnv().tier;
|
||||
const configPath = resolveGatewayConfigPath(anchor);
|
||||
const anchoredConfigExists = existsSync(configPath);
|
||||
const resolvedTier = loadConfig(configPath).tier;
|
||||
const configuredTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||
const databaseUrlDeterminesTier = envOnlyTier === 'standalone' && configuredTier !== 'standalone';
|
||||
const recognizedTierDeterminesTier =
|
||||
(configuredTier === 'federated' ||
|
||||
configuredTier === 'standalone' ||
|
||||
configuredTier === 'local') &&
|
||||
configuredTier === envOnlyTier;
|
||||
|
||||
let source: BootSource;
|
||||
if (anchoredConfigExists) {
|
||||
source = 'mosaic.config.json';
|
||||
} else if (databaseUrlDeterminesTier && databaseUrlSource !== 'default') {
|
||||
source = databaseUrlSource;
|
||||
} else if (recognizedTierDeterminesTier && tierSource !== 'default') {
|
||||
source = tierSource;
|
||||
} else {
|
||||
source = 'default';
|
||||
}
|
||||
|
||||
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);
|
||||
}
|
||||
|
||||
loadGatewayEnv();
|
||||
@@ -1,164 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
type INestApplication,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { HarnessRegistry } from './harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
||||
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||
// Import the REAL module (not a hand-listed controllers+mocks list) so an
|
||||
// unresolved provider fails at app.init() — the #1145-class DI-boot guard.
|
||||
import { HarnessModule } from './harness.module.js';
|
||||
|
||||
// A known-available tuple from the fake adapter's default catalog.
|
||||
const VALID = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-mini' };
|
||||
// A tuple whose provider/model are not in any catalog.
|
||||
const UNKNOWN = { harnessId: 'fake', providerId: 'ghost-provider', modelId: 'ghost-model' };
|
||||
// A tuple that is known in the catalog but flagged unavailable.
|
||||
const UNAVAILABLE = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-legacy' };
|
||||
|
||||
const authGuard: CanActivate = {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
||||
requestContext.user = { id: 'user-1' };
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function registryWithFake(): HarnessRegistry {
|
||||
const registry = new HarnessRegistry();
|
||||
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||
return registry;
|
||||
}
|
||||
|
||||
describe('Harness selection HTTP surface', () => {
|
||||
let app: INestApplication;
|
||||
let repository: HarnessSelectionRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [HarnessModule],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(authGuard)
|
||||
.overrideProvider(HARNESS_REGISTRY)
|
||||
.useValue(registryWithFake())
|
||||
.compile();
|
||||
|
||||
// Real in-memory repository from the module graph — proves the module wired it.
|
||||
repository = moduleRef.get(HarnessSelectionRepository);
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||
);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset owner-scoped state between tests via the public API surface.
|
||||
repository.set({ userId: 'user-1', tenantId: 'user-1' }, VALID);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET selection is server-scoped and ignores caller-supplied scope in the query', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/chat/preferences/selection')
|
||||
.query({ userId: 'attacker', tenantId: 'attacker-tenant', seatId: 'attacker-seat' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// The returned selection is user-1's (guard-derived scope), not the query's.
|
||||
expect(response.body.selection).toEqual(VALID);
|
||||
});
|
||||
|
||||
it('PUT with a valid structured tuple persists and round-trips via GET', async () => {
|
||||
const next = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-pro' };
|
||||
|
||||
const put = await request(app.getHttpServer())
|
||||
.put('/api/chat/preferences/selection')
|
||||
.send(next)
|
||||
.set('Content-Type', 'application/json');
|
||||
expect(put.status).toBe(200);
|
||||
expect(put.body.selection).toEqual(next);
|
||||
|
||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||
expect(get.status).toBe(200);
|
||||
expect(get.body.selection).toEqual(next);
|
||||
});
|
||||
|
||||
it('PUT with FREE TEXT is rejected 400 and does not mutate the stored selection', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.put('/api/chat/preferences/selection')
|
||||
.send({ selection: 'gpt-4o' })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||
expect(get.body.selection).toEqual(VALID);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['seatId', { ...VALID, seatId: 'attacker-seat' }],
|
||||
['tenantId', { ...VALID, tenantId: 'attacker-tenant' }],
|
||||
['userId', { ...VALID, userId: 'attacker' }],
|
||||
['nativeSessionPath', { ...VALID, nativeSessionPath: '/var/native/x.jsonl' }],
|
||||
['executable', { ...VALID, executable: '/usr/bin/evil' }],
|
||||
['home', { ...VALID, home: '/home/attacker' }],
|
||||
['cwd', { ...VALID, cwd: '/tmp/attacker' }],
|
||||
])(
|
||||
'PUT with an extra authority-bearing field (%s) is rejected 400 and does not mutate stored selection',
|
||||
async (_name, body) => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.put('/api/chat/preferences/selection')
|
||||
.send(body)
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||
expect(get.body.selection).toEqual(VALID);
|
||||
},
|
||||
);
|
||||
|
||||
it('PUT with an UNKNOWN tuple returns selection_invalid, unchanged and echoed unchanged (no fallback)', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.put('/api/chat/preferences/selection')
|
||||
.send(UNKNOWN)
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.code).toBe('selection_invalid');
|
||||
// Echoed back unchanged: no first-row / first-provider substitution.
|
||||
expect(response.body.selection).toEqual(UNKNOWN);
|
||||
|
||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||
expect(get.body.selection).toEqual(VALID);
|
||||
});
|
||||
|
||||
it('PUT with a KNOWN-but-UNAVAILABLE tuple returns model_unavailable, unchanged (distinct from selection_invalid)', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.put('/api/chat/preferences/selection')
|
||||
.send(UNAVAILABLE)
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.code).toBe('model_unavailable');
|
||||
expect(response.body.selection).toEqual(UNAVAILABLE);
|
||||
|
||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||
expect(get.body.selection).toEqual(VALID);
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Body, Controller, Get, HttpException, HttpStatus, Put, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||
import { HarnessOperationError } from './harness.registry.js';
|
||||
import { HarnessSelectionService } from './harness-selection.service.js';
|
||||
import { HarnessSelectionInputDto, type SelectionResponseDto } from './harness.dto.js';
|
||||
|
||||
/**
|
||||
* Chat-preferences selection surface. The scope is ALWAYS derived on the server
|
||||
* from the authenticated user (`scopeFromUser(CurrentUser)`); the request body and
|
||||
* query string can never name another user, tenant, or seat. A typed selection
|
||||
* failure (unknown tuple → `selection_invalid`, known-but-unavailable →
|
||||
* `model_unavailable`) is returned as 422 with the requested tuple echoed back
|
||||
* unchanged, and never mutates the stored selection.
|
||||
*/
|
||||
@Controller('api/chat/preferences/selection')
|
||||
@UseGuards(AuthGuard)
|
||||
export class HarnessSelectionController {
|
||||
constructor(private readonly selection: HarnessSelectionService) {}
|
||||
|
||||
@Get()
|
||||
get(@CurrentUser() user: AuthenticatedUserLike): SelectionResponseDto {
|
||||
return { selection: this.selection.getSelection(scopeFromUser(user)) };
|
||||
}
|
||||
|
||||
@Put()
|
||||
async put(
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Body() dto: HarnessSelectionInputDto,
|
||||
): Promise<SelectionResponseDto> {
|
||||
try {
|
||||
const stored = await this.selection.setSelection(scopeFromUser(user), {
|
||||
harnessId: dto.harnessId,
|
||||
providerId: dto.providerId,
|
||||
modelId: dto.modelId,
|
||||
});
|
||||
return { selection: stored };
|
||||
} catch (error) {
|
||||
if (error instanceof HarnessOperationError) {
|
||||
throw new HttpException(error.dto, HttpStatus.UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,90 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { HarnessSelection } from '@mosaicstack/types';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import {
|
||||
HarnessAdapterUnavailableError,
|
||||
HarnessRegistry,
|
||||
operationError,
|
||||
} from './harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||
import { readContextFromScope } from './harness.dto.js';
|
||||
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
||||
|
||||
/**
|
||||
* Selection logic for the Slice-Zero chat-preferences surface. It validates the
|
||||
* requested harness/provider/model tuple against the live catalog with NO
|
||||
* fallback substitution, then persists it owner-scoped. The stored selection is
|
||||
* only ever mutated when the tuple is valid AND available.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HarnessSelectionService {
|
||||
constructor(
|
||||
@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry,
|
||||
private readonly repository: HarnessSelectionRepository,
|
||||
) {}
|
||||
|
||||
getSelection(scope: ActorTenantScope): HarnessSelection | null {
|
||||
return this.repository.get(scope);
|
||||
}
|
||||
|
||||
async setSelection(
|
||||
scope: ActorTenantScope,
|
||||
selection: HarnessSelection,
|
||||
): Promise<HarnessSelection> {
|
||||
// Throws HarnessOperationError (selection_invalid / model_unavailable) with the
|
||||
// requested tuple echoed back unchanged. The store is untouched on any throw.
|
||||
await this.assertSelectionAvailable(scope, selection);
|
||||
return this.repository.set(scope, selection);
|
||||
}
|
||||
|
||||
private async assertSelectionAvailable(
|
||||
scope: ActorTenantScope,
|
||||
selection: HarnessSelection,
|
||||
): Promise<void> {
|
||||
const correlationId = randomUUID();
|
||||
|
||||
let adapter;
|
||||
try {
|
||||
adapter = this.registry.get(selection.harnessId);
|
||||
} catch (error) {
|
||||
if (error instanceof HarnessAdapterUnavailableError) {
|
||||
// An unknown harness makes the whole tuple invalid — no fallback adapter.
|
||||
throw operationError(
|
||||
'selection_invalid',
|
||||
'The requested harness/provider/model tuple is not in the catalog.',
|
||||
selection,
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const catalog = await adapter.catalog(readContextFromScope(scope));
|
||||
const entry = catalog.models.find(
|
||||
(candidate) =>
|
||||
candidate.harnessId === selection.harnessId &&
|
||||
candidate.providerId === selection.providerId &&
|
||||
candidate.modelId === selection.modelId,
|
||||
);
|
||||
|
||||
if (!entry) {
|
||||
// No first-row / first-provider fallback: reject the requested tuple unchanged.
|
||||
throw operationError(
|
||||
'selection_invalid',
|
||||
'The requested harness/provider/model tuple is not in the catalog.',
|
||||
selection,
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
if (entry.availability === 'unavailable') {
|
||||
throw operationError(
|
||||
'model_unavailable',
|
||||
'The requested model is currently unavailable.',
|
||||
selection,
|
||||
correlationId,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
type INestApplication,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { HarnessRegistry } from './harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||
// The real module under test — importing it (not a hand-listed controllers/mocks
|
||||
// list) is what makes an unresolved provider fail loudly at app.init() (#1145 guard).
|
||||
import { HarnessModule } from './harness.module.js';
|
||||
|
||||
// Fields that must NEVER surface on a browser-facing catalog/list response.
|
||||
const FORBIDDEN_KEYS = [
|
||||
'executable',
|
||||
'executablePath',
|
||||
'home',
|
||||
'homeDir',
|
||||
'cwd',
|
||||
'workingDir',
|
||||
'workingDirectory',
|
||||
'nativeSessionPath',
|
||||
'sessionPath',
|
||||
'env',
|
||||
'secret',
|
||||
'secrets',
|
||||
'token',
|
||||
'apiKey',
|
||||
];
|
||||
|
||||
function assertNoForbiddenLeak(payload: unknown): void {
|
||||
const serialized = JSON.stringify(payload).toLowerCase();
|
||||
for (const key of FORBIDDEN_KEYS) {
|
||||
expect(serialized).not.toContain(key.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
const authGuard: CanActivate = {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
||||
requestContext.user = { id: 'user-1' };
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function registryWithFake(): HarnessRegistry {
|
||||
const registry = new HarnessRegistry();
|
||||
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||
return registry;
|
||||
}
|
||||
|
||||
describe('Harness catalog HTTP surface', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [HarnessModule],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(authGuard)
|
||||
.overrideProvider(HARNESS_REGISTRY)
|
||||
.useValue(registryWithFake())
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||
);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('boots the real HarnessModule so all providers resolve at app.init()', () => {
|
||||
// If HarnessModule failed to resolve a provider, beforeAll's app.init() would
|
||||
// have thrown and this suite would never reach here.
|
||||
expect(app).toBeDefined();
|
||||
});
|
||||
|
||||
it('GET /api/harnesses returns 200 with safe fields only', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/harnesses');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBeGreaterThan(0);
|
||||
const summary = response.body[0];
|
||||
expect(Object.keys(summary).sort()).toEqual(['capabilities', 'displayName', 'id']);
|
||||
expect(summary.id).toBe('fake');
|
||||
expect(typeof summary.displayName).toBe('string');
|
||||
expect(Array.isArray(summary.capabilities)).toBe(true);
|
||||
assertNoForbiddenLeak(response.body);
|
||||
});
|
||||
|
||||
it('GET /api/harnesses/:harnessId/catalog returns 200 with safe catalog fields only', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/harnesses/fake/catalog');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.harnessId).toBe('fake');
|
||||
expect(typeof response.body.version).toBe('string');
|
||||
expect(typeof response.body.fingerprint).toBe('string');
|
||||
expect(Array.isArray(response.body.models)).toBe(true);
|
||||
expect(response.body.models.length).toBeGreaterThan(0);
|
||||
const entry = response.body.models[0];
|
||||
// Whitelisted catalog-entry fields only (no executables/paths/secrets).
|
||||
expect(Object.keys(entry).sort()).toEqual(
|
||||
[
|
||||
'authState',
|
||||
'availability',
|
||||
'displayName',
|
||||
'harnessId',
|
||||
'inputTypes',
|
||||
'modelId',
|
||||
'providerId',
|
||||
'reasoningCapability',
|
||||
].sort(),
|
||||
);
|
||||
assertNoForbiddenLeak(response.body);
|
||||
});
|
||||
|
||||
it('GET catalog for an unknown harnessId returns a typed adapter_unavailable error, never a fallback catalog', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/harnesses/ghost-harness/catalog');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.code).toBe('adapter_unavailable');
|
||||
// A fallback catalog would carry a models array; a typed error must not.
|
||||
expect(response.body.models).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||
import { HarnessAdapterUnavailableError, HarnessRegistry } from './harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||
import {
|
||||
readContextFromScope,
|
||||
toHarnessSummary,
|
||||
toSafeCatalog,
|
||||
type HarnessCatalogDto,
|
||||
type HarnessSummaryDto,
|
||||
} from './harness.dto.js';
|
||||
|
||||
/**
|
||||
* Generic harness catalog surface. It exposes only harness-neutral, browser-safe
|
||||
* fields (identity, capabilities, provider/model catalog) — never executables,
|
||||
* native paths, home/cwd, env, or secrets. There is NO provider-probe route here;
|
||||
* `/api/providers` and `POST /api/providers/test` are intentionally out of scope.
|
||||
*/
|
||||
@Controller('api/harnesses')
|
||||
@UseGuards(AuthGuard)
|
||||
export class HarnessController {
|
||||
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
|
||||
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AuthenticatedUserLike): Promise<HarnessSummaryDto[]> {
|
||||
const context = readContextFromScope(scopeFromUser(user));
|
||||
const summaries: HarnessSummaryDto[] = [];
|
||||
for (const adapter of this.registry.list()) {
|
||||
summaries.push(toHarnessSummary(await adapter.describe(context)));
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
@Get(':harnessId/catalog')
|
||||
async catalog(
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Param('harnessId') harnessId: string,
|
||||
): Promise<HarnessCatalogDto> {
|
||||
const context = readContextFromScope(scopeFromUser(user));
|
||||
let adapter;
|
||||
try {
|
||||
adapter = this.registry.get(harnessId);
|
||||
} catch (error) {
|
||||
if (error instanceof HarnessAdapterUnavailableError) {
|
||||
// Typed failure — NEVER a fallback catalog for an unknown harness id.
|
||||
throw new HttpException(
|
||||
{ code: error.code, message: error.message, harnessId },
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return toSafeCatalog(await adapter.catalog(context));
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import type {
|
||||
HarnessActorContext,
|
||||
HarnessAuthState,
|
||||
HarnessCapability,
|
||||
HarnessCatalog,
|
||||
HarnessCatalogEntry,
|
||||
HarnessDescriptor,
|
||||
HarnessInputType,
|
||||
HarnessModelAvailability,
|
||||
HarnessSelection,
|
||||
} from '@mosaicstack/types';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
|
||||
/**
|
||||
* Structured selection tuple accepted on `PUT /api/chat/preferences/selection`.
|
||||
*
|
||||
* The body is a STRUCTURED tuple (harness + provider + model), never a free-text
|
||||
* model string. With `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })`
|
||||
* any extra property — including smuggled server-authority fields such as
|
||||
* `seatId`, `tenantId`, `userId`, `nativeSessionPath`, `executable`, `home`, `cwd` —
|
||||
* is rejected with 400. There is deliberately no field through which a caller can
|
||||
* name a scope; scope is derived on the server from the authenticated session.
|
||||
*/
|
||||
export class HarnessSelectionInputDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
harnessId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
providerId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
modelId!: string;
|
||||
}
|
||||
|
||||
/** Browser-safe harness summary — identity and capabilities only. */
|
||||
export interface HarnessSummaryDto {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly capabilities: readonly HarnessCapability[];
|
||||
}
|
||||
|
||||
/** Browser-safe catalog entry — no executables, paths, secrets, or env. */
|
||||
export interface HarnessCatalogEntryDto {
|
||||
readonly harnessId: string;
|
||||
readonly providerId: string;
|
||||
readonly modelId: string;
|
||||
readonly displayName: string;
|
||||
readonly reasoningCapability: boolean;
|
||||
readonly inputTypes: readonly HarnessInputType[];
|
||||
readonly authState: HarnessAuthState;
|
||||
readonly availability: HarnessModelAvailability;
|
||||
}
|
||||
|
||||
/** Browser-safe catalog envelope. */
|
||||
export interface HarnessCatalogDto {
|
||||
readonly harnessId: string;
|
||||
readonly version: string;
|
||||
readonly fingerprint: string;
|
||||
readonly models: readonly HarnessCatalogEntryDto[];
|
||||
}
|
||||
|
||||
/** Response envelope for the caller's current selection (null when unset). */
|
||||
export interface SelectionResponseDto {
|
||||
readonly selection: HarnessSelection | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a server-trusted {@link HarnessActorContext} for read operations from the
|
||||
* session-derived {@link ActorTenantScope}. All authority originates on the server;
|
||||
* nothing here is caller-supplied. A fresh correlation id is minted per call.
|
||||
*/
|
||||
export function readContextFromScope(scope: ActorTenantScope): HarnessActorContext {
|
||||
return {
|
||||
actorId: scope.userId,
|
||||
tenantId: scope.tenantId,
|
||||
seatId: scope.userId,
|
||||
correlationId: randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Project a descriptor onto the browser-safe summary shape (whitelist by construction). */
|
||||
export function toHarnessSummary(descriptor: HarnessDescriptor): HarnessSummaryDto {
|
||||
return {
|
||||
id: descriptor.id,
|
||||
displayName: descriptor.displayName,
|
||||
capabilities: [...descriptor.capabilities],
|
||||
};
|
||||
}
|
||||
|
||||
/** Project a catalog onto the browser-safe shape (whitelist by construction). */
|
||||
export function toSafeCatalog(catalog: HarnessCatalog): HarnessCatalogDto {
|
||||
return {
|
||||
harnessId: catalog.harnessId,
|
||||
version: catalog.version,
|
||||
fingerprint: catalog.fingerprint,
|
||||
models: catalog.models.map(toSafeCatalogEntry),
|
||||
};
|
||||
}
|
||||
|
||||
function toSafeCatalogEntry(entry: HarnessCatalogEntry): HarnessCatalogEntryDto {
|
||||
return {
|
||||
harnessId: entry.harnessId,
|
||||
providerId: entry.providerId,
|
||||
modelId: entry.modelId,
|
||||
displayName: entry.displayName,
|
||||
reasoningCapability: entry.reasoningCapability,
|
||||
inputTypes: [...entry.inputTypes],
|
||||
authState: entry.authState,
|
||||
availability: entry.availability,
|
||||
};
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HarnessRegistry } from './harness.registry.js';
|
||||
import { HarnessService } from './harness.service.js';
|
||||
import { HARNESS_REGISTRY, HARNESS_SERVICE } from './harness.tokens.js';
|
||||
import { HarnessController } from './harness.controller.js';
|
||||
import { HarnessSelectionController } from './harness-selection.controller.js';
|
||||
import { HarnessSelectionService } from './harness-selection.service.js';
|
||||
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
||||
|
||||
/**
|
||||
* Wires the harness-neutral registry/service (Task Two) together with the
|
||||
* Slice-Zero catalog and selection HTTP surfaces (Task Three).
|
||||
*
|
||||
* The registry is provided empty here; real harness adapters are registered in a
|
||||
* later task. Because the controllers/services resolve their collaborators through
|
||||
* this real module graph, an unresolved provider fails loudly at `app.init()`.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [HarnessController, HarnessSelectionController],
|
||||
providers: [
|
||||
{ provide: HARNESS_REGISTRY, useFactory: () => new HarnessRegistry() },
|
||||
{ provide: HARNESS_SERVICE, useClass: HarnessService },
|
||||
HarnessSelectionRepository,
|
||||
HarnessSelectionService,
|
||||
],
|
||||
exports: [HARNESS_REGISTRY, HARNESS_SERVICE],
|
||||
})
|
||||
export class HarnessModule {}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
HarnessAdapterUnavailableError,
|
||||
HarnessRegistrationError,
|
||||
HarnessRegistry,
|
||||
} from './harness.registry.js';
|
||||
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||
|
||||
describe('HarnessRegistry', () => {
|
||||
it('registers and looks up an adapter by harness id', () => {
|
||||
const registry = new HarnessRegistry();
|
||||
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||
|
||||
registry.register(adapter);
|
||||
|
||||
expect(registry.get('fake')).toBe(adapter);
|
||||
expect(registry.has('fake')).toBe(true);
|
||||
expect(registry.list().map((entry) => entry.id)).toEqual(['fake']);
|
||||
});
|
||||
|
||||
it('rejects a blank adapter id', () => {
|
||||
const registry = new HarnessRegistry();
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
registry.register(new FakeHarnessAdapter({ id: ' ' }));
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessRegistrationError);
|
||||
expect((error as HarnessRegistrationError).reason).toBe('blank_id');
|
||||
expect(registry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a duplicate adapter id', () => {
|
||||
const registry = new HarnessRegistry();
|
||||
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessRegistrationError);
|
||||
expect((error as HarnessRegistrationError).reason).toBe('duplicate_id');
|
||||
expect((error as HarnessRegistrationError).harnessId).toBe('fake');
|
||||
// The original registration is untouched.
|
||||
expect(registry.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns adapter_unavailable for an unknown harness id', () => {
|
||||
const registry = new HarnessRegistry();
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
registry.get('missing');
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessAdapterUnavailableError);
|
||||
expect((error as HarnessAdapterUnavailableError).code).toBe('adapter_unavailable');
|
||||
expect((error as HarnessAdapterUnavailableError).harnessId).toBe('missing');
|
||||
expect(registry.has('missing')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
HarnessAdapter,
|
||||
HarnessErrorCode,
|
||||
HarnessErrorDto,
|
||||
HarnessSelection,
|
||||
} from '@mosaicstack/types';
|
||||
|
||||
/**
|
||||
* A typed harness operation failure that carries a fully-formed, browser-safe
|
||||
* {@link HarnessErrorDto}. The DTO's `selection` is always the exact requested
|
||||
* tuple — there is no field through which a substituted "effective" selection
|
||||
* could ever be reported.
|
||||
*/
|
||||
export class HarnessOperationError extends Error {
|
||||
readonly code: HarnessErrorCode;
|
||||
readonly dto: HarnessErrorDto;
|
||||
|
||||
constructor(dto: HarnessErrorDto) {
|
||||
super(dto.message);
|
||||
this.name = 'HarnessOperationError';
|
||||
this.code = dto.code;
|
||||
this.dto = dto;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a {@link HarnessOperationError} that echoes the requested selection unchanged. */
|
||||
export function operationError(
|
||||
code: HarnessErrorCode,
|
||||
message: string,
|
||||
selection: HarnessSelection,
|
||||
correlationId: string,
|
||||
retryable = false,
|
||||
): HarnessOperationError {
|
||||
return new HarnessOperationError({ code, message, retryable, correlationId, selection });
|
||||
}
|
||||
|
||||
/** Raised when an unknown harness id is looked up. Discriminated by `code`. */
|
||||
export class HarnessAdapterUnavailableError extends Error {
|
||||
readonly code = 'adapter_unavailable' as const satisfies HarnessErrorCode;
|
||||
|
||||
constructor(readonly harnessId: string) {
|
||||
super(`No harness adapter is registered for id "${harnessId}".`);
|
||||
this.name = 'HarnessAdapterUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export type HarnessRegistrationFailure = 'blank_id' | 'duplicate_id';
|
||||
|
||||
/** Raised when an adapter cannot be registered (blank or duplicate id). */
|
||||
export class HarnessRegistrationError extends Error {
|
||||
constructor(
|
||||
readonly reason: HarnessRegistrationFailure,
|
||||
readonly harnessId: string,
|
||||
) {
|
||||
super(
|
||||
reason === 'blank_id'
|
||||
? 'A harness adapter id must be a non-empty string.'
|
||||
: `A harness adapter is already registered for id "${harnessId}".`,
|
||||
);
|
||||
this.name = 'HarnessRegistrationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Harness-neutral adapter registry. Adapters are keyed by their harness id.
|
||||
* Registration rejects blank and duplicate ids; lookup of an unknown id fails
|
||||
* with {@link HarnessAdapterUnavailableError} (`adapter_unavailable`).
|
||||
*/
|
||||
@Injectable()
|
||||
export class HarnessRegistry {
|
||||
private readonly adapters = new Map<string, HarnessAdapter>();
|
||||
|
||||
register(adapter: HarnessAdapter): void {
|
||||
const id = adapter.id;
|
||||
if (typeof id !== 'string' || id.trim().length === 0) {
|
||||
throw new HarnessRegistrationError('blank_id', id ?? '');
|
||||
}
|
||||
if (this.adapters.has(id)) {
|
||||
throw new HarnessRegistrationError('duplicate_id', id);
|
||||
}
|
||||
this.adapters.set(id, adapter);
|
||||
}
|
||||
|
||||
get(harnessId: string): HarnessAdapter {
|
||||
const adapter = this.adapters.get(harnessId);
|
||||
if (!adapter) {
|
||||
throw new HarnessAdapterUnavailableError(harnessId);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
has(harnessId: string): boolean {
|
||||
return this.adapters.has(harnessId);
|
||||
}
|
||||
|
||||
list(): readonly HarnessAdapter[] {
|
||||
return [...this.adapters.values()];
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { HarnessActorContext, HarnessCapability, HarnessSelection } from '@mosaicstack/types';
|
||||
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
|
||||
import { HarnessOperationError, HarnessRegistry } from './harness.registry.js';
|
||||
import {
|
||||
HarnessScopeViolationError,
|
||||
HarnessService,
|
||||
type TrustedGatewayScope,
|
||||
} from './harness.service.js';
|
||||
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||
|
||||
const SCOPE: TrustedGatewayScope = {
|
||||
actorId: 'actor-trusted',
|
||||
tenantId: 'tenant-trusted',
|
||||
seatId: 'seat-trusted',
|
||||
correlationId: 'correlation-trusted',
|
||||
};
|
||||
|
||||
const READ_CONTEXT: HarnessActorContext = {
|
||||
actorId: SCOPE.actorId,
|
||||
tenantId: SCOPE.tenantId,
|
||||
seatId: SCOPE.seatId,
|
||||
correlationId: SCOPE.correlationId,
|
||||
};
|
||||
|
||||
function setup(capabilities?: readonly HarnessCapability[]) {
|
||||
const registry = new HarnessRegistry();
|
||||
const adapter = new FakeHarnessAdapter({ id: 'fake', capabilities });
|
||||
registry.register(adapter);
|
||||
const service = new HarnessService(registry);
|
||||
return { registry, adapter, service };
|
||||
}
|
||||
|
||||
async function availableSelection(adapter: FakeHarnessAdapter): Promise<HarnessSelection> {
|
||||
const catalog = await adapter.catalog(READ_CONTEXT);
|
||||
const entry = catalog.models.find((model) => model.availability === 'available');
|
||||
if (!entry) {
|
||||
throw new Error('fixture requires an available model');
|
||||
}
|
||||
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
|
||||
}
|
||||
|
||||
describe('HarnessService', () => {
|
||||
it('derives the actor context from trusted scope on create', async () => {
|
||||
const { service, adapter } = setup();
|
||||
const selection = await availableSelection(adapter);
|
||||
|
||||
const snapshot = await service.createSession(SCOPE, {
|
||||
conversationId: 'conversation-1',
|
||||
selection,
|
||||
});
|
||||
|
||||
expect(snapshot.seatId).toBe(SCOPE.seatId);
|
||||
expect(snapshot.state).toBe('idle');
|
||||
expect(snapshot.selection).toEqual(selection);
|
||||
expect(snapshot.nativeSessionId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects server-authority fields supplied by an external caller', async () => {
|
||||
const { service, adapter } = setup();
|
||||
const selection = await availableSelection(adapter);
|
||||
|
||||
const hostile = {
|
||||
conversationId: 'conversation-1',
|
||||
selection,
|
||||
seatId: 'attacker-seat',
|
||||
executablePath: '/usr/bin/evil',
|
||||
home: '/home/attacker',
|
||||
cwd: '/tmp/attacker',
|
||||
nativeSessionPath: '/var/native/attacker.jsonl',
|
||||
} as unknown as Parameters<HarnessService['createSession']>[1];
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await service.createSession(SCOPE, hostile);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessScopeViolationError);
|
||||
expect((error as HarnessScopeViolationError).field).toBe('seatId');
|
||||
});
|
||||
|
||||
it('returns adapter_unavailable for an unknown harness id, echoing the requested tuple', async () => {
|
||||
const { service } = setup();
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'ghost-harness',
|
||||
providerId: 'p',
|
||||
modelId: 'm',
|
||||
};
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
const dto = (error as HarnessOperationError).dto;
|
||||
expect(dto.code).toBe('adapter_unavailable');
|
||||
expect(dto.selection).toEqual(selection);
|
||||
expect(dto.correlationId).toBe(SCOPE.correlationId);
|
||||
});
|
||||
|
||||
it('returns selection_invalid for an unknown provider/model tuple, unchanged', async () => {
|
||||
const { service } = setup();
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'fake',
|
||||
providerId: 'ghost-provider',
|
||||
modelId: 'ghost-model',
|
||||
};
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
const dto = (error as HarnessOperationError).dto;
|
||||
expect(dto.code).toBe('selection_invalid');
|
||||
expect(dto.selection).toEqual(selection);
|
||||
});
|
||||
|
||||
it('returns model_unavailable without falling back for a known unavailable model', async () => {
|
||||
const { service, adapter } = setup();
|
||||
const catalog = await adapter.catalog(READ_CONTEXT);
|
||||
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
|
||||
expect(unavailable).toBeDefined();
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: unavailable!.harnessId,
|
||||
providerId: unavailable!.providerId,
|
||||
modelId: unavailable!.modelId,
|
||||
};
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
const dto = (error as HarnessOperationError).dto;
|
||||
expect(dto.code).toBe('model_unavailable');
|
||||
// No substitution: the DTO tuple is exactly what was requested.
|
||||
expect(dto.selection).toEqual(selection);
|
||||
});
|
||||
|
||||
it('gives create, resume, detach, evict, and end distinct observable effects', async () => {
|
||||
const { service, adapter } = setup();
|
||||
const selection = await availableSelection(adapter);
|
||||
|
||||
const created = await service.createSession(SCOPE, {
|
||||
conversationId: 'conversation-create',
|
||||
selection,
|
||||
});
|
||||
expect(created.state).toBe('idle');
|
||||
expect(created.processId).toBeTruthy();
|
||||
expect(created.attachedClientIds).toEqual([]);
|
||||
|
||||
const resumed = await service.resumeSession(SCOPE, {
|
||||
conversationId: 'conversation-resume',
|
||||
nativeSessionId: 'native-preexisting-123',
|
||||
selection,
|
||||
});
|
||||
// Resume binds the supplied native session; create mints a fresh one.
|
||||
expect(resumed.nativeSessionId).toBe('native-preexisting-123');
|
||||
expect(resumed.nativeSessionId).not.toBe(created.nativeSessionId);
|
||||
|
||||
await service.attach(SCOPE, {
|
||||
conversationId: 'conversation-create',
|
||||
clientId: 'browser-1',
|
||||
});
|
||||
const afterAttach = await service.snapshot(SCOPE, 'conversation-create');
|
||||
expect(afterAttach.attachedClientIds).toEqual(['browser-1']);
|
||||
|
||||
const afterDetach = await service.detach(SCOPE, {
|
||||
conversationId: 'conversation-create',
|
||||
clientId: 'browser-1',
|
||||
});
|
||||
// Detach removes the browser attachment only; the process stays alive.
|
||||
expect(afterDetach.attachedClientIds).toEqual([]);
|
||||
expect(afterDetach.state).toBe('idle');
|
||||
expect(afterDetach.processId).toBeTruthy();
|
||||
|
||||
const afterEvict = await service.evict(SCOPE, {
|
||||
conversationId: 'conversation-create',
|
||||
reason: 'idle_timeout',
|
||||
});
|
||||
// Evict stops the process but retains the resumable native session.
|
||||
expect(afterEvict.state).toBe('evicted');
|
||||
expect(afterEvict.processId).toBeUndefined();
|
||||
expect(afterEvict.nativeSessionId).toBe(created.nativeSessionId);
|
||||
|
||||
const afterEnd = await service.end(SCOPE, {
|
||||
conversationId: 'conversation-create',
|
||||
reason: 'session_ended',
|
||||
});
|
||||
// End destructively terminates the native session.
|
||||
expect(afterEnd.state).toBe('ended');
|
||||
});
|
||||
|
||||
it('fails typed when an unsupported capability is exercised', async () => {
|
||||
const withoutExtensionUi = HARNESS_CAPABILITIES.filter(
|
||||
(capability) => capability !== 'extensionUi',
|
||||
);
|
||||
const { service, adapter } = setup(withoutExtensionUi);
|
||||
const selection = await availableSelection(adapter);
|
||||
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await service.respondInteraction(SCOPE, {
|
||||
conversationId: 'conversation-1',
|
||||
response: { requestId: 'interaction-1', type: 'confirm', accepted: true },
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
|
||||
});
|
||||
});
|
||||
@@ -1,285 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
HarnessActorContext,
|
||||
HarnessAdapter,
|
||||
HarnessCatalog,
|
||||
HarnessCloseReason,
|
||||
HarnessInteractionResponse,
|
||||
HarnessSelection,
|
||||
HarnessSessionHandle,
|
||||
HarnessSessionSnapshot,
|
||||
} from '@mosaicstack/types';
|
||||
import {
|
||||
HarnessAdapterUnavailableError,
|
||||
HarnessRegistry,
|
||||
operationError,
|
||||
} from './harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||
|
||||
/**
|
||||
* Trusted, server-derived authority. In production this is produced by the
|
||||
* Gateway from the authenticated session — never from a browser/caller DTO.
|
||||
*/
|
||||
export interface TrustedGatewayScope {
|
||||
readonly actorId: string;
|
||||
readonly tenantId: string;
|
||||
readonly seatId: string;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
/** Server-authority fields that must never arrive from an external request DTO. */
|
||||
const FORBIDDEN_REQUEST_FIELDS = [
|
||||
'actorId',
|
||||
'tenantId',
|
||||
'correlationId',
|
||||
'seatId',
|
||||
'seat',
|
||||
'executable',
|
||||
'executablePath',
|
||||
'home',
|
||||
'homeDir',
|
||||
'cwd',
|
||||
'workingDir',
|
||||
'workingDirectory',
|
||||
'nativeSessionPath',
|
||||
'sessionPath',
|
||||
] as const;
|
||||
|
||||
/** Raised when an external request DTO smuggles a server-authority field. */
|
||||
export class HarnessScopeViolationError extends Error {
|
||||
constructor(readonly field: string) {
|
||||
super(`External request supplied server-authority field "${field}".`);
|
||||
this.name = 'HarnessScopeViolationError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateHarnessSessionRequest {
|
||||
readonly conversationId: string;
|
||||
readonly selection: HarnessSelection;
|
||||
}
|
||||
|
||||
export interface ResumeHarnessSessionRequest {
|
||||
readonly conversationId: string;
|
||||
readonly nativeSessionId: string;
|
||||
readonly selection: HarnessSelection;
|
||||
}
|
||||
|
||||
export interface AttachClientRequest {
|
||||
readonly conversationId: string;
|
||||
readonly clientId: string;
|
||||
}
|
||||
|
||||
export interface DetachClientRequest {
|
||||
readonly conversationId: string;
|
||||
readonly clientId: string;
|
||||
}
|
||||
|
||||
export interface EvictSessionRequest {
|
||||
readonly conversationId: string;
|
||||
readonly reason: HarnessCloseReason;
|
||||
}
|
||||
|
||||
export interface EndSessionRequest {
|
||||
readonly conversationId: string;
|
||||
readonly reason: HarnessCloseReason;
|
||||
}
|
||||
|
||||
export interface RespondInteractionRequest {
|
||||
readonly conversationId: string;
|
||||
readonly response: HarnessInteractionResponse;
|
||||
}
|
||||
|
||||
interface ActiveSession {
|
||||
readonly harnessId: string;
|
||||
readonly handle: HarnessSessionHandle;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Harness-neutral service. It derives the {@link HarnessActorContext} strictly
|
||||
* from trusted Gateway scope, validates the selected provider/model tuple with
|
||||
* NO fallback substitution, and exposes distinct create/resume/detach/evict/end
|
||||
* lifecycle operations.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HarnessService {
|
||||
private readonly sessions = new Map<string, ActiveSession>();
|
||||
|
||||
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
|
||||
|
||||
async createSession(
|
||||
scope: TrustedGatewayScope,
|
||||
request: CreateHarnessSessionRequest,
|
||||
): Promise<HarnessSessionSnapshot> {
|
||||
assertTrustedRequest(request);
|
||||
const { conversationId, selection } = request;
|
||||
const adapter = this.resolveAdapter(scope, selection);
|
||||
const context = deriveActorContext(scope);
|
||||
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
||||
|
||||
const handle = await adapter.create({ context, conversationId, selection });
|
||||
this.sessions.set(conversationId, {
|
||||
harnessId: selection.harnessId,
|
||||
handle,
|
||||
correlationId: scope.correlationId,
|
||||
});
|
||||
return handle.snapshot();
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
scope: TrustedGatewayScope,
|
||||
request: ResumeHarnessSessionRequest,
|
||||
): Promise<HarnessSessionSnapshot> {
|
||||
assertTrustedRequest(request);
|
||||
const { conversationId, nativeSessionId, selection } = request;
|
||||
const adapter = this.resolveAdapter(scope, selection);
|
||||
const context = deriveActorContext(scope);
|
||||
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
||||
|
||||
const handle = await adapter.resume({ context, conversationId, nativeSessionId, selection });
|
||||
this.sessions.set(conversationId, {
|
||||
harnessId: selection.harnessId,
|
||||
handle,
|
||||
correlationId: scope.correlationId,
|
||||
});
|
||||
return handle.snapshot();
|
||||
}
|
||||
|
||||
async attach(
|
||||
scope: TrustedGatewayScope,
|
||||
request: AttachClientRequest,
|
||||
): Promise<HarnessSessionSnapshot> {
|
||||
assertTrustedRequest(request);
|
||||
const handle = this.requireHandle(scope, request.conversationId);
|
||||
await handle.attach({ clientId: request.clientId });
|
||||
return handle.snapshot();
|
||||
}
|
||||
|
||||
async detach(
|
||||
scope: TrustedGatewayScope,
|
||||
request: DetachClientRequest,
|
||||
): Promise<HarnessSessionSnapshot> {
|
||||
assertTrustedRequest(request);
|
||||
const handle = this.requireHandle(scope, request.conversationId);
|
||||
await handle.detach(request.clientId);
|
||||
return handle.snapshot();
|
||||
}
|
||||
|
||||
async evict(
|
||||
scope: TrustedGatewayScope,
|
||||
request: EvictSessionRequest,
|
||||
): Promise<HarnessSessionSnapshot> {
|
||||
assertTrustedRequest(request);
|
||||
const handle = this.requireHandle(scope, request.conversationId);
|
||||
await handle.evictProcess(request.reason);
|
||||
return handle.snapshot();
|
||||
}
|
||||
|
||||
async end(
|
||||
scope: TrustedGatewayScope,
|
||||
request: EndSessionRequest,
|
||||
): Promise<HarnessSessionSnapshot> {
|
||||
assertTrustedRequest(request);
|
||||
const handle = this.requireHandle(scope, request.conversationId);
|
||||
await handle.endSession(request.reason);
|
||||
const snapshot = await handle.snapshot();
|
||||
this.sessions.delete(request.conversationId);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async respondInteraction(
|
||||
scope: TrustedGatewayScope,
|
||||
request: RespondInteractionRequest,
|
||||
): Promise<void> {
|
||||
assertTrustedRequest(request);
|
||||
const handle = this.requireHandle(scope, request.conversationId);
|
||||
await handle.respondInteraction(request.response);
|
||||
}
|
||||
|
||||
async snapshot(
|
||||
scope: TrustedGatewayScope,
|
||||
conversationId: string,
|
||||
): Promise<HarnessSessionSnapshot> {
|
||||
const handle = this.requireHandle(scope, conversationId);
|
||||
return handle.snapshot();
|
||||
}
|
||||
|
||||
private resolveAdapter(scope: TrustedGatewayScope, selection: HarnessSelection): HarnessAdapter {
|
||||
try {
|
||||
return this.registry.get(selection.harnessId);
|
||||
} catch (error) {
|
||||
if (error instanceof HarnessAdapterUnavailableError) {
|
||||
throw operationError('adapter_unavailable', error.message, selection, scope.correlationId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async assertSelectionAvailable(
|
||||
scope: TrustedGatewayScope,
|
||||
catalogPromise: Promise<HarnessCatalog>,
|
||||
selection: HarnessSelection,
|
||||
): Promise<void> {
|
||||
const catalog = await catalogPromise;
|
||||
const entry = catalog.models.find(
|
||||
(candidate) =>
|
||||
candidate.harnessId === selection.harnessId &&
|
||||
candidate.providerId === selection.providerId &&
|
||||
candidate.modelId === selection.modelId,
|
||||
);
|
||||
if (!entry) {
|
||||
// No first-row fallback: reject the requested tuple unchanged.
|
||||
throw operationError(
|
||||
'selection_invalid',
|
||||
'The requested harness/provider/model tuple is not in the catalog.',
|
||||
selection,
|
||||
scope.correlationId,
|
||||
);
|
||||
}
|
||||
if (entry.availability === 'unavailable') {
|
||||
throw operationError(
|
||||
'model_unavailable',
|
||||
'The requested model is currently unavailable.',
|
||||
selection,
|
||||
scope.correlationId,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private requireHandle(scope: TrustedGatewayScope, conversationId: string): HarnessSessionHandle {
|
||||
const active = this.sessions.get(conversationId);
|
||||
if (!active) {
|
||||
throw operationError(
|
||||
'session_not_found',
|
||||
`No active harness session for conversation "${conversationId}".`,
|
||||
{ harnessId: '', providerId: '', modelId: '' },
|
||||
scope.correlationId,
|
||||
);
|
||||
}
|
||||
return active.handle;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the actor context strictly from trusted scope. No caller data leaks in. */
|
||||
export function deriveActorContext(scope: TrustedGatewayScope): HarnessActorContext {
|
||||
return {
|
||||
actorId: scope.actorId,
|
||||
tenantId: scope.tenantId,
|
||||
seatId: scope.seatId,
|
||||
correlationId: scope.correlationId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reject any request object that carries a server-authority field. */
|
||||
function assertTrustedRequest(request: object): void {
|
||||
for (const field of FORBIDDEN_REQUEST_FIELDS) {
|
||||
if (Object.prototype.hasOwnProperty.call(request, field)) {
|
||||
throw new HarnessScopeViolationError(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export the typed operation error so callers importing from the service
|
||||
// have the discriminated failure type without reaching into the registry.
|
||||
export { HarnessOperationError } from './harness.registry.js';
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Nest dependency-injection tokens for the harness-neutral registry and service.
|
||||
*
|
||||
* String tokens follow the existing Gateway convention (see `memory/memory.tokens.ts`)
|
||||
* and remain valid Nest `InjectionToken`s for `@Inject(...)`.
|
||||
*/
|
||||
export const HARNESS_REGISTRY = 'HARNESS_REGISTRY' as const;
|
||||
export const HARNESS_SERVICE = 'HARNESS_SERVICE' as const;
|
||||
|
||||
export type HarnessRegistryToken = typeof HARNESS_REGISTRY;
|
||||
export type HarnessServiceToken = typeof HARNESS_SERVICE;
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { HarnessActorContext, HarnessSelection } from '@mosaicstack/types';
|
||||
import { HarnessOperationError } from '../harness.registry.js';
|
||||
import { FakeHarnessAdapter } from './fake-harness.adapter.js';
|
||||
import { runHarnessAdapterContract } from './harness-adapter.contract.js';
|
||||
|
||||
const CONTEXT: HarnessActorContext = {
|
||||
actorId: 'actor-1',
|
||||
tenantId: 'tenant-1',
|
||||
seatId: 'seat-1',
|
||||
correlationId: 'correlation-1',
|
||||
};
|
||||
|
||||
// The reusable conformance suite. Task 13 re-runs it against the native Pi adapter.
|
||||
runHarnessAdapterContract('FakeHarnessAdapter', () => new FakeHarnessAdapter({ id: 'fake' }));
|
||||
|
||||
describe('FakeHarnessAdapter no-substitution', () => {
|
||||
it('never substitutes the first catalog row when a bogus selection is requested', async () => {
|
||||
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||
const catalog = await adapter.catalog(CONTEXT);
|
||||
const firstRow = catalog.models[0];
|
||||
if (!firstRow) {
|
||||
throw new Error('fixture requires a catalog model');
|
||||
}
|
||||
const available = catalog.models.find(
|
||||
(entry) => entry.availability === 'available' && entry.modelId !== firstRow.modelId,
|
||||
);
|
||||
expect(available).toBeDefined();
|
||||
const selected: HarnessSelection = {
|
||||
harnessId: available!.harnessId,
|
||||
providerId: available!.providerId,
|
||||
modelId: available!.modelId,
|
||||
};
|
||||
|
||||
const handle = await adapter.create({
|
||||
context: CONTEXT,
|
||||
conversationId: 'conversation-1',
|
||||
selection: selected,
|
||||
});
|
||||
|
||||
const bogus: HarnessSelection = {
|
||||
harnessId: 'fake',
|
||||
providerId: 'ghost-provider',
|
||||
modelId: 'ghost-model',
|
||||
};
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await handle.setModel(bogus);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
const dto = (error as HarnessOperationError).dto;
|
||||
expect(dto.code).toBe('selection_invalid');
|
||||
// The DTO echoes the exact requested tuple, unchanged.
|
||||
expect(dto.selection).toEqual(bogus);
|
||||
// No substitution to the first catalog row.
|
||||
expect(dto.selection).not.toEqual({
|
||||
harnessId: firstRow.harnessId,
|
||||
providerId: firstRow.providerId,
|
||||
modelId: firstRow.modelId,
|
||||
});
|
||||
// The active selection is untouched by the rejected request.
|
||||
expect((await handle.snapshot()).selection).toEqual(selected);
|
||||
});
|
||||
|
||||
it('reports model_unavailable with the unchanged tuple for a known but unavailable model', async () => {
|
||||
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||
const catalog = await adapter.catalog(CONTEXT);
|
||||
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
|
||||
const available = catalog.models.find((entry) => entry.availability === 'available');
|
||||
expect(unavailable).toBeDefined();
|
||||
expect(available).toBeDefined();
|
||||
|
||||
const startingSelection: HarnessSelection = {
|
||||
harnessId: available!.harnessId,
|
||||
providerId: available!.providerId,
|
||||
modelId: available!.modelId,
|
||||
};
|
||||
const handle = await adapter.create({
|
||||
context: CONTEXT,
|
||||
conversationId: 'conversation-2',
|
||||
selection: startingSelection,
|
||||
});
|
||||
|
||||
const requested: HarnessSelection = {
|
||||
harnessId: unavailable!.harnessId,
|
||||
providerId: unavailable!.providerId,
|
||||
modelId: unavailable!.modelId,
|
||||
};
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await handle.setModel(requested);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
const dto = (error as HarnessOperationError).dto;
|
||||
expect(dto.code).toBe('model_unavailable');
|
||||
expect(dto.selection).toEqual(requested);
|
||||
expect((await handle.snapshot()).selection).toEqual(startingSelection);
|
||||
});
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
import type {
|
||||
AttachClient,
|
||||
CreateHarnessSession,
|
||||
HarnessAdapter,
|
||||
HarnessActorContext,
|
||||
HarnessCapability,
|
||||
HarnessCatalog,
|
||||
HarnessCatalogEntry,
|
||||
HarnessCloseReason,
|
||||
HarnessDescriptor,
|
||||
HarnessEvent,
|
||||
HarnessInteractionResponse,
|
||||
HarnessPrompt,
|
||||
HarnessPromptReceipt,
|
||||
HarnessSelection,
|
||||
HarnessSessionHandle,
|
||||
HarnessSessionSnapshot,
|
||||
HarnessSessionState,
|
||||
ResumeHarnessSession,
|
||||
} from '@mosaicstack/types';
|
||||
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
|
||||
import { operationError } from '../harness.registry.js';
|
||||
|
||||
export interface FakeHarnessAdapterOptions {
|
||||
readonly id: string;
|
||||
readonly capabilities?: readonly HarnessCapability[];
|
||||
readonly catalog?: readonly HarnessCatalogEntry[];
|
||||
}
|
||||
|
||||
const FAKE_PROVIDER = 'fake-openai';
|
||||
|
||||
function defaultCatalog(harnessId: string): readonly HarnessCatalogEntry[] {
|
||||
return [
|
||||
{
|
||||
harnessId,
|
||||
providerId: FAKE_PROVIDER,
|
||||
modelId: 'fake-mini',
|
||||
displayName: 'Fake Mini',
|
||||
reasoningCapability: false,
|
||||
inputTypes: ['text'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
{
|
||||
harnessId,
|
||||
providerId: FAKE_PROVIDER,
|
||||
modelId: 'fake-pro',
|
||||
displayName: 'Fake Pro',
|
||||
reasoningCapability: true,
|
||||
inputTypes: ['text', 'image'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
{
|
||||
harnessId,
|
||||
providerId: FAKE_PROVIDER,
|
||||
modelId: 'fake-legacy',
|
||||
displayName: 'Fake Legacy',
|
||||
reasoningCapability: false,
|
||||
inputTypes: ['text'],
|
||||
authState: 'unavailable',
|
||||
availability: 'unavailable',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function matches(entry: HarnessCatalogEntry, selection: HarnessSelection): boolean {
|
||||
return (
|
||||
entry.harnessId === selection.harnessId &&
|
||||
entry.providerId === selection.providerId &&
|
||||
entry.modelId === selection.modelId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory harness session handle used by the fake adapter and by the shared
|
||||
* conformance suite. It enforces the two invariants the real adapters must also
|
||||
* honor: model selection is validated against the catalog and is NEVER
|
||||
* substituted, and unsupported capabilities fail with a typed error.
|
||||
*/
|
||||
export class FakeHarnessSessionHandle implements HarnessSessionHandle {
|
||||
private state: HarnessSessionState = 'idle';
|
||||
private processId: string | undefined;
|
||||
private readonly attachedClientIds = new Set<string>();
|
||||
private readonly listeners = new Set<(event: HarnessEvent) => void>();
|
||||
|
||||
constructor(
|
||||
private readonly conversationId: string,
|
||||
private readonly nativeSessionId: string,
|
||||
private readonly seatId: string,
|
||||
private selection: HarnessSelection,
|
||||
private readonly correlationId: string,
|
||||
private readonly capabilities: readonly HarnessCapability[],
|
||||
private readonly catalog: readonly HarnessCatalogEntry[],
|
||||
) {
|
||||
this.processId = `process-${nativeSessionId}`;
|
||||
}
|
||||
|
||||
async snapshot(): Promise<HarnessSessionSnapshot> {
|
||||
return {
|
||||
conversationId: this.conversationId,
|
||||
nativeSessionId: this.nativeSessionId,
|
||||
processId: this.processId,
|
||||
seatId: this.seatId,
|
||||
selection: this.selection,
|
||||
state: this.state,
|
||||
attachedClientIds: [...this.attachedClientIds],
|
||||
};
|
||||
}
|
||||
|
||||
async attach(input: AttachClient): Promise<void> {
|
||||
this.attachedClientIds.add(input.clientId);
|
||||
}
|
||||
|
||||
async detach(clientId: string): Promise<void> {
|
||||
// Removes the browser attachment only; the process and native session persist.
|
||||
this.attachedClientIds.delete(clientId);
|
||||
}
|
||||
|
||||
async prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise<HarnessPromptReceipt> {
|
||||
return {
|
||||
conversationId: this.conversationId,
|
||||
turnId: input.turnId,
|
||||
correlationId: input.correlationId,
|
||||
state: 'accepted',
|
||||
selection: this.selection,
|
||||
};
|
||||
}
|
||||
|
||||
async setModel(selection: HarnessSelection): Promise<HarnessSelection> {
|
||||
const entry = this.catalog.find((candidate) => matches(candidate, selection));
|
||||
if (!entry) {
|
||||
// No fallback to the first catalog row: reject with the requested tuple, unchanged.
|
||||
throw operationError(
|
||||
'selection_invalid',
|
||||
'The requested harness/provider/model tuple is not in the catalog.',
|
||||
selection,
|
||||
this.correlationId,
|
||||
);
|
||||
}
|
||||
if (entry.availability === 'unavailable') {
|
||||
throw operationError(
|
||||
'model_unavailable',
|
||||
'The requested model is currently unavailable.',
|
||||
selection,
|
||||
this.correlationId,
|
||||
true,
|
||||
);
|
||||
}
|
||||
this.selection = selection;
|
||||
return this.selection;
|
||||
}
|
||||
|
||||
async abort(_turnId: string): Promise<void> {
|
||||
// No active turn machinery in the fake; abort is a no-op acknowledgement.
|
||||
}
|
||||
|
||||
async respondInteraction(_input: HarnessInteractionResponse): Promise<void> {
|
||||
if (!this.capabilities.includes('extensionUi')) {
|
||||
throw operationError(
|
||||
'interaction_unsupported',
|
||||
'This harness does not support interactive responses.',
|
||||
this.selection,
|
||||
this.correlationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
events(listener: (event: HarnessEvent) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async evictProcess(_reason: HarnessCloseReason): Promise<void> {
|
||||
// Stop the process but keep the resumable native session.
|
||||
this.processId = undefined;
|
||||
this.state = 'evicted';
|
||||
}
|
||||
|
||||
async endSession(_reason: HarnessCloseReason): Promise<void> {
|
||||
// Destructively end the native session.
|
||||
this.processId = undefined;
|
||||
this.state = 'ended';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal in-memory {@link HarnessAdapter} for Slice Zero. It mints a fresh
|
||||
* native session id on `create` and binds the supplied one on `resume`, so the
|
||||
* two paths are observably distinct.
|
||||
*/
|
||||
export class FakeHarnessAdapter implements HarnessAdapter {
|
||||
readonly id: string;
|
||||
private readonly capabilities: readonly HarnessCapability[];
|
||||
private readonly catalogEntries: readonly HarnessCatalogEntry[];
|
||||
private createdCount = 0;
|
||||
|
||||
constructor(options: FakeHarnessAdapterOptions) {
|
||||
this.id = options.id;
|
||||
this.capabilities = options.capabilities ?? [...HARNESS_CAPABILITIES];
|
||||
this.catalogEntries = options.catalog ?? defaultCatalog(options.id);
|
||||
}
|
||||
|
||||
async describe(_context: HarnessActorContext): Promise<HarnessDescriptor> {
|
||||
return {
|
||||
id: this.id,
|
||||
displayName: `Fake harness (${this.id})`,
|
||||
capabilities: this.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
async catalog(_context: HarnessActorContext): Promise<HarnessCatalog> {
|
||||
return {
|
||||
harnessId: this.id,
|
||||
version: '1.0.0',
|
||||
fingerprint: `fake-${this.id}-${this.catalogEntries.length}`,
|
||||
models: this.catalogEntries,
|
||||
};
|
||||
}
|
||||
|
||||
async create(input: CreateHarnessSession): Promise<HarnessSessionHandle> {
|
||||
this.createdCount += 1;
|
||||
const nativeSessionId = `native-${input.conversationId}-${this.createdCount}`;
|
||||
return new FakeHarnessSessionHandle(
|
||||
input.conversationId,
|
||||
nativeSessionId,
|
||||
input.context.seatId,
|
||||
input.selection,
|
||||
input.context.correlationId,
|
||||
this.capabilities,
|
||||
this.catalogEntries,
|
||||
);
|
||||
}
|
||||
|
||||
async resume(input: ResumeHarnessSession): Promise<HarnessSessionHandle> {
|
||||
return new FakeHarnessSessionHandle(
|
||||
input.conversationId,
|
||||
input.nativeSessionId,
|
||||
input.context.seatId,
|
||||
input.selection,
|
||||
input.context.correlationId,
|
||||
this.capabilities,
|
||||
this.catalogEntries,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
HarnessActorContext,
|
||||
HarnessAdapter,
|
||||
HarnessCatalogEntry,
|
||||
HarnessSelection,
|
||||
} from '@mosaicstack/types';
|
||||
import { HarnessOperationError } from '../harness.registry.js';
|
||||
|
||||
const CONTEXT: HarnessActorContext = {
|
||||
actorId: 'contract-actor',
|
||||
tenantId: 'contract-tenant',
|
||||
seatId: 'contract-seat',
|
||||
correlationId: 'contract-correlation',
|
||||
};
|
||||
|
||||
function toSelection(entry: HarnessCatalogEntry): HarnessSelection {
|
||||
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
|
||||
}
|
||||
|
||||
function pickAvailable(models: readonly HarnessCatalogEntry[]): HarnessCatalogEntry {
|
||||
const entry = models.find((candidate) => candidate.availability === 'available') ?? models[0];
|
||||
if (!entry) {
|
||||
throw new Error('contract fixture requires at least one catalog model');
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function captureError(run: () => Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await run();
|
||||
return undefined;
|
||||
} catch (caught) {
|
||||
return caught;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared conformance suite every {@link HarnessAdapter} must pass. Slice Zero
|
||||
* runs it against the fake adapter; Task 13 re-runs the identical suite against
|
||||
* the native Pi adapter so both share one behavioral contract.
|
||||
*/
|
||||
export function runHarnessAdapterContract(
|
||||
label: string,
|
||||
createAdapter: () => HarnessAdapter,
|
||||
): void {
|
||||
describe(`harness adapter contract: ${label}`, () => {
|
||||
it('mints a fresh native session on create and binds the supplied one on resume', async () => {
|
||||
const adapter = createAdapter();
|
||||
const catalog = await adapter.catalog(CONTEXT);
|
||||
const selection = toSelection(pickAvailable(catalog.models));
|
||||
|
||||
const created = await (
|
||||
await adapter.create({ context: CONTEXT, conversationId: 'conv-create', selection })
|
||||
).snapshot();
|
||||
const resumed = await (
|
||||
await adapter.resume({
|
||||
context: CONTEXT,
|
||||
conversationId: 'conv-resume',
|
||||
nativeSessionId: 'native-supplied-1',
|
||||
selection,
|
||||
})
|
||||
).snapshot();
|
||||
|
||||
expect(created.nativeSessionId).toBeTruthy();
|
||||
expect(resumed.nativeSessionId).toBe('native-supplied-1');
|
||||
expect(created.nativeSessionId).not.toBe(resumed.nativeSessionId);
|
||||
expect(created.seatId).toBe(CONTEXT.seatId);
|
||||
});
|
||||
|
||||
it('gives detach, evict, and end distinct effects (not aliases)', async () => {
|
||||
const adapter = createAdapter();
|
||||
const catalog = await adapter.catalog(CONTEXT);
|
||||
const selection = toSelection(pickAvailable(catalog.models));
|
||||
const handle = await adapter.create({
|
||||
context: CONTEXT,
|
||||
conversationId: 'conv-lifecycle',
|
||||
selection,
|
||||
});
|
||||
|
||||
await handle.attach({ clientId: 'browser-1' });
|
||||
await handle.detach('browser-1');
|
||||
const afterDetach = await handle.snapshot();
|
||||
expect(afterDetach.attachedClientIds).toEqual([]);
|
||||
expect(afterDetach.state).not.toBe('evicted');
|
||||
expect(afterDetach.state).not.toBe('ended');
|
||||
|
||||
await handle.evictProcess('idle_timeout');
|
||||
const afterEvict = await handle.snapshot();
|
||||
expect(afterEvict.state).toBe('evicted');
|
||||
// The native session survives eviction (resumable); the process does not.
|
||||
expect(afterEvict.nativeSessionId).toBe(afterDetach.nativeSessionId);
|
||||
expect(afterEvict.processId).toBeUndefined();
|
||||
|
||||
await handle.endSession('session_ended');
|
||||
const afterEnd = await handle.snapshot();
|
||||
expect(afterEnd.state).toBe('ended');
|
||||
// End is not an alias of evict.
|
||||
expect(afterEnd.state).not.toBe(afterEvict.state);
|
||||
});
|
||||
|
||||
it('never substitutes the first catalog row for an unknown selection', async () => {
|
||||
const adapter = createAdapter();
|
||||
const catalog = await adapter.catalog(CONTEXT);
|
||||
const firstRow = catalog.models[0];
|
||||
if (!firstRow) {
|
||||
throw new Error('contract fixture requires a catalog model');
|
||||
}
|
||||
const start = toSelection(pickAvailable(catalog.models));
|
||||
const handle = await adapter.create({
|
||||
context: CONTEXT,
|
||||
conversationId: 'conv-nosub',
|
||||
selection: start,
|
||||
});
|
||||
|
||||
const bogus: HarnessSelection = {
|
||||
harnessId: adapter.id,
|
||||
providerId: 'contract-ghost-provider',
|
||||
modelId: 'contract-ghost-model',
|
||||
};
|
||||
const error = await captureError(() => handle.setModel(bogus));
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
const dto = (error as HarnessOperationError).dto;
|
||||
expect(dto.code).toBe('selection_invalid');
|
||||
expect(dto.selection).toEqual(bogus);
|
||||
expect(dto.selection).not.toEqual(toSelection(firstRow));
|
||||
expect((await handle.snapshot()).selection).toEqual(start);
|
||||
});
|
||||
|
||||
it('validates capability-gated interactions with a typed error, not a silent no-op', async () => {
|
||||
const adapter = createAdapter();
|
||||
const descriptor = await adapter.describe(CONTEXT);
|
||||
const catalog = await adapter.catalog(CONTEXT);
|
||||
const selection = toSelection(pickAvailable(catalog.models));
|
||||
const handle = await adapter.create({
|
||||
context: CONTEXT,
|
||||
conversationId: 'conv-interaction',
|
||||
selection,
|
||||
});
|
||||
|
||||
const response = {
|
||||
requestId: 'interaction-1',
|
||||
type: 'confirm',
|
||||
accepted: true,
|
||||
} as const;
|
||||
|
||||
if (descriptor.capabilities.includes('extensionUi')) {
|
||||
await expect(handle.respondInteraction(response)).resolves.toBeUndefined();
|
||||
} else {
|
||||
const error = await captureError(() => handle.respondInteraction(response));
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import * as nodeOs from 'node:os';
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import * as nodeUrl from 'node:url';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import type * as MosaicStorage from '@mosaicstack/storage';
|
||||
import { describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
|
||||
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
||||
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
||||
|
||||
function snapshotProcessEnv(): Record<string, string | undefined> {
|
||||
return { ...process.env };
|
||||
}
|
||||
|
||||
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in snapshot)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
||||
const relativePath = relative(tempRoot, path);
|
||||
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
||||
expectPathUnderTempRoot(path, tempRoot);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, contents, 'utf8');
|
||||
}
|
||||
|
||||
interface BootstrapPreflightResult {
|
||||
capturedConfig: MosaicConfig | undefined;
|
||||
}
|
||||
|
||||
async function runBootstrapPreflight(
|
||||
anchoredConfigContents: string,
|
||||
ambientConfigContents: string,
|
||||
): Promise<BootstrapPreflightResult> {
|
||||
const originalEnv = snapshotProcessEnv();
|
||||
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-main-preflight-'));
|
||||
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
let exitSpy: MockInstance<typeof process.exit> | undefined;
|
||||
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
let capturedConfig: MosaicConfig | undefined;
|
||||
|
||||
try {
|
||||
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
||||
const homePath = join(tempRoot, 'home');
|
||||
const cwdPath = join(tempRoot, 'ambient', 'cwd');
|
||||
const monorepoRootConfigPath = resolve(anchor, '../../..', 'mosaic.config.json');
|
||||
|
||||
await mkdir(anchor, { recursive: true });
|
||||
await mkdir(cwdPath, { recursive: true });
|
||||
|
||||
await writeFixture(monorepoRootConfigPath, anchoredConfigContents, tempRoot);
|
||||
await writeFixture(join(cwdPath, 'mosaic.config.json'), ambientConfigContents, tempRoot);
|
||||
|
||||
process.env['HOME'] = homePath;
|
||||
process.env['BETTER_AUTH_SECRET'] = 'fixture-secret';
|
||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||
delete process.env['DATABASE_URL'];
|
||||
delete process.env['VALKEY_URL'];
|
||||
|
||||
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
||||
const exitMock = vi.fn<typeof process.exit>();
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation(exitMock);
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
||||
vi.doMock('node:url', () => ({
|
||||
...nodeUrl,
|
||||
fileURLToPath: (url: string | URL): string => {
|
||||
const actualPath = nodeUrl.fileURLToPath(url);
|
||||
if (
|
||||
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
||||
actualPath.endsWith('/apps/gateway/src/env.js')
|
||||
) {
|
||||
return join(anchor, 'env.ts');
|
||||
}
|
||||
return actualPath;
|
||||
},
|
||||
}));
|
||||
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
||||
vi.doMock('./tracing.js', () => ({}));
|
||||
|
||||
const preflightSentinel = new Error('preflight-capture-sentinel');
|
||||
vi.doMock('@mosaicstack/storage', async () => {
|
||||
const actual = await vi.importActual<typeof MosaicStorage>('@mosaicstack/storage');
|
||||
return {
|
||||
...actual,
|
||||
detectAndAssertTier: vi.fn((config: MosaicConfig): Promise<void> => {
|
||||
capturedConfig = config;
|
||||
throw preflightSentinel;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
await import('./main.js');
|
||||
await vi.waitFor((): void => {
|
||||
expect(exitSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
return { capturedConfig };
|
||||
} finally {
|
||||
cwdSpy?.mockRestore();
|
||||
exitSpy?.mockRestore();
|
||||
consoleInfoSpy?.mockRestore();
|
||||
vi.doUnmock('@mosaicstack/storage');
|
||||
vi.doUnmock('./tracing.js');
|
||||
vi.doUnmock('node:url');
|
||||
vi.doUnmock('node:os');
|
||||
vi.resetModules();
|
||||
restoreProcessEnv(originalEnv);
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('main bootstrap preflight config anchoring', (): void => {
|
||||
it(
|
||||
'passes the anchored monorepo-root config to detectAndAssertTier, not an ambient cwd config',
|
||||
async (): Promise<void> => {
|
||||
const anchoredConfig = JSON.stringify({
|
||||
tier: 'local',
|
||||
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
||||
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
||||
memory: { type: 'keyword' },
|
||||
});
|
||||
const ambientConfig = JSON.stringify({
|
||||
tier: 'federated',
|
||||
storage: {
|
||||
type: 'postgres',
|
||||
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
||||
enableVector: true,
|
||||
},
|
||||
queue: { type: 'bullmq' },
|
||||
memory: { type: 'pgvector' },
|
||||
});
|
||||
|
||||
const { capturedConfig } = await runBootstrapPreflight(anchoredConfig, ambientConfig);
|
||||
|
||||
expect(capturedConfig?.tier).toBe('local');
|
||||
expect(capturedConfig?.storage).not.toEqual(
|
||||
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
||||
);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
import './env.js';
|
||||
import { config } from 'dotenv';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
// Load .env from daemon config dir (global install / daemon mode).
|
||||
// Loaded first so monorepo .env can override for local dev.
|
||||
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
|
||||
if (existsSync(daemonEnv)) config({ path: daemonEnv });
|
||||
|
||||
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
|
||||
config({ path: resolve(process.cwd(), '../../.env') });
|
||||
config(); // Also load apps/gateway/.env if present (overrides)
|
||||
|
||||
import './tracing.js';
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
@@ -13,7 +26,6 @@ import { mountAuthHandler } from './auth/auth.controller.js';
|
||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||
import { McpService } from './mcp/mcp.service.js';
|
||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||
import { resolveGatewayConfigPath } from './env.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
@@ -25,7 +37,7 @@ async function bootstrap(): Promise<void> {
|
||||
// Pre-flight: assert all external services required by the configured tier
|
||||
// are reachable. Runs before NestFactory.create() so failures are visible
|
||||
// immediately with actionable remediation hints.
|
||||
const mosaicConfig = loadConfig(resolveGatewayConfigPath());
|
||||
const mosaicConfig = loadConfig();
|
||||
try {
|
||||
await detectAndAssertTier(mosaicConfig);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { McpClientService } from './mcp-client.service.js';
|
||||
|
||||
const MCP_LEAK_MARKER = 'MCP_LEAK_MARKER /srv/secret';
|
||||
|
||||
describe('McpClientService — failed connect error sanitization', () => {
|
||||
const originalMcpServers = process.env['MCP_SERVERS'];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['MCP_SERVERS'] = JSON.stringify([
|
||||
{ name: 'leaky-server', url: 'http://localhost:9999/mcp' },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalMcpServers === undefined) {
|
||||
delete process.env['MCP_SERVERS'];
|
||||
} else {
|
||||
process.env['MCP_SERVERS'] = originalMcpServers;
|
||||
}
|
||||
});
|
||||
|
||||
it('stores a generic serverEntry.error while logging the raw exception server-side', async () => {
|
||||
vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error(MCP_LEAK_MARKER));
|
||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const service = new McpClientService();
|
||||
await service.onModuleInit();
|
||||
|
||||
const statuses = service.getServerStatuses();
|
||||
expect(statuses).toHaveLength(1);
|
||||
expect(statuses[0]?.connected).toBe(false);
|
||||
expect(statuses[0]?.error).toBe('Connection failed (see server logs).');
|
||||
expect(statuses[0]?.error).not.toContain(MCP_LEAK_MARKER);
|
||||
|
||||
const loggedRawMarker = errorSpy.mock.calls.some((call) =>
|
||||
call.some((arg) => typeof arg === 'string' && arg.includes(MCP_LEAK_MARKER)),
|
||||
);
|
||||
expect(loggedRawMarker).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -189,7 +189,7 @@ export class McpClientService implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
serverEntry.error = 'Connection failed (see server logs).';
|
||||
serverEntry.error = message;
|
||||
serverEntry.connected = false;
|
||||
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SlashCommandPayload, SystemReloadPayload } from '@mosaicstack/types';
|
||||
import { ReloadService } from './reload.service.js';
|
||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||
|
||||
function createMockCommandRegistry() {
|
||||
return {
|
||||
@@ -107,85 +104,3 @@ describe('ReloadService', () => {
|
||||
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
||||
it('generic per-plugin errors reach the chat surface while raw markers stay server-side only', async () => {
|
||||
const registry = {
|
||||
getManifest: vi.fn().mockReturnValue({
|
||||
version: 1,
|
||||
commands: [
|
||||
{ name: 'reload', aliases: [], scope: 'core', execution: 'socket', available: true },
|
||||
],
|
||||
skills: [],
|
||||
}),
|
||||
};
|
||||
const reloadService = new ReloadService(registry as never);
|
||||
|
||||
const RELOAD_LOAD_LEAK_MARKER = 'RELOAD_LOAD_LEAK_MARKER /srv/load-secret';
|
||||
const RELOAD_UNLOAD_LEAK_MARKER = 'RELOAD_UNLOAD_LEAK_MARKER /srv/unload-secret';
|
||||
|
||||
reloadService.registerPlugin('unload-fails', {
|
||||
pluginName: 'unload-fails',
|
||||
onLoad: vi.fn().mockResolvedValue(undefined),
|
||||
onUnload: vi.fn().mockRejectedValue(new Error(RELOAD_UNLOAD_LEAK_MARKER)),
|
||||
});
|
||||
reloadService.registerPlugin('load-fails', {
|
||||
pluginName: 'load-fails',
|
||||
onLoad: vi.fn().mockRejectedValue(new Error(RELOAD_LOAD_LEAK_MARKER)),
|
||||
onUnload: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const broadcastReload = vi.fn();
|
||||
const mockChatGateway = { broadcastReload };
|
||||
const mockAgentService = { getSession: vi.fn(), applyAgentConfig: vi.fn() };
|
||||
const mockSystemOverride = { set: vi.fn(), get: vi.fn(), clear: vi.fn() };
|
||||
const mockSessionGC = { sweepOrphans: vi.fn() };
|
||||
const mockBrain = { agents: { findByName: vi.fn(), findById: vi.fn(), create: vi.fn() } };
|
||||
|
||||
const mockMcpClient = {
|
||||
getServerStatuses: vi.fn(() => []),
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const executor = new CommandExecutorService(
|
||||
registry as never,
|
||||
mockAgentService as never,
|
||||
mockSystemOverride as never,
|
||||
mockSessionGC as never,
|
||||
null,
|
||||
mockBrain as never,
|
||||
reloadService,
|
||||
mockChatGateway as never,
|
||||
mockMcpClient as never,
|
||||
);
|
||||
|
||||
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
||||
const result = await executor.execute(payload, { userId: 'user-1', tenantId: 'user-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('unload-fails: unload failed (internal error)');
|
||||
expect(result.message).toContain('load-fails: load failed (internal error)');
|
||||
expect(result.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
||||
expect(result.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
||||
|
||||
expect(broadcastReload).toHaveBeenCalledOnce();
|
||||
const broadcastPayload = broadcastReload.mock.calls[0]?.[0] as SystemReloadPayload;
|
||||
expect(broadcastPayload.message).toContain('unload-fails: unload failed (internal error)');
|
||||
expect(broadcastPayload.message).toContain('load-fails: load failed (internal error)');
|
||||
expect(broadcastPayload.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
||||
expect(broadcastPayload.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
||||
|
||||
const loggedUnloadMarker = errorSpy.mock.calls.some((call) =>
|
||||
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_UNLOAD_LEAK_MARKER)),
|
||||
);
|
||||
const loggedLoadMarker = errorSpy.mock.calls.some((call) =>
|
||||
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_LOAD_LEAK_MARKER)),
|
||||
);
|
||||
expect(loggedUnloadMarker).toBe(true);
|
||||
expect(loggedLoadMarker).toBe(true);
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,8 +58,7 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
||||
await plugin.onUnload();
|
||||
reloaded.push(name);
|
||||
} catch (err) {
|
||||
this.logger.error(`Plugin "${name}" failed during onUnload: ${err}`);
|
||||
errors.push(`${name}: unload failed (internal error)`);
|
||||
errors.push(`${name}: unload failed — ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,8 +69,7 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
||||
try {
|
||||
await plugin.onLoad();
|
||||
} catch (err) {
|
||||
this.logger.error(`Plugin "${name}" failed during onLoad: ${err}`);
|
||||
errors.push(`${name}: load failed (internal error)`);
|
||||
errors.push(`${name}: load failed — ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
type INestApplication,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||
import { WorkspaceController } from './workspace.controller.js';
|
||||
|
||||
const bootstrapMock = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
projectId: 'project-1',
|
||||
workspacePath: '/opt/mosaic/.workspaces/users/user-1/project-1',
|
||||
}),
|
||||
);
|
||||
|
||||
const authGuard: CanActivate = {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
||||
requestContext.user = { id: 'user-1' };
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
describe('POST /api/workspaces repoUrl validation', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [WorkspaceController],
|
||||
providers: [
|
||||
{
|
||||
provide: ProjectBootstrapService,
|
||||
useValue: { bootstrap: bootstrapMock },
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(authGuard)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
bootstrapMock.mockClear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a leading-dash value', '--upload-pack=sh -c id'],
|
||||
['an ext remote helper', 'ext::sh -c id'],
|
||||
['a file URL', 'file:///tmp/repository'],
|
||||
['an unparseable value', 'not a url'],
|
||||
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
||||
['a scheme without //', 'https:example.com/acme/repository.git'],
|
||||
['a hostless git URL', 'git:///tmp/repository'],
|
||||
])('returns 400 for %s', async (_description, repoUrl) => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/workspaces')
|
||||
.send({ name: 'Example', repoUrl })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(bootstrapMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a plain HTTPS repository URL', 'https://example.com/acme/repository.git'],
|
||||
['a git protocol repository URL', 'git://example.com/acme/repository.git'],
|
||||
])('accepts %s', async (_description, repoUrl) => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/workspaces')
|
||||
.send({ name: 'Example', repoUrl })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(bootstrapMock).toHaveBeenCalledWith({
|
||||
name: 'Example',
|
||||
description: undefined,
|
||||
userId: 'user-1',
|
||||
teamId: undefined,
|
||||
repoUrl,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import {
|
||||
ProjectBootstrapService,
|
||||
type BootstrapProjectResult,
|
||||
} from './project-bootstrap.service.js';
|
||||
import { CreateWorkspaceDto } from './workspace.dto.js';
|
||||
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||
|
||||
@Controller('api/workspaces')
|
||||
@UseGuards(AuthGuard)
|
||||
@@ -15,14 +11,20 @@ export class WorkspaceController {
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body() dto: CreateWorkspaceDto,
|
||||
): Promise<BootstrapProjectResult> {
|
||||
@Body()
|
||||
body: {
|
||||
name: string;
|
||||
description?: string;
|
||||
teamId?: string;
|
||||
repoUrl?: string;
|
||||
},
|
||||
) {
|
||||
return this.bootstrap.bootstrap({
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
userId: user.id,
|
||||
teamId: dto.teamId,
|
||||
repoUrl: dto.repoUrl,
|
||||
teamId: body.teamId,
|
||||
repoUrl: body.repoUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { IsOptional, IsString, IsUrl, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateWorkspaceDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
teamId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^(?:https|git):\/\//i, {
|
||||
message: 'repoUrl must be a valid https:// or git:// URL',
|
||||
})
|
||||
@IsUrl(
|
||||
{
|
||||
protocols: ['https', 'git'],
|
||||
require_host: true,
|
||||
require_protocol: true,
|
||||
require_tld: false,
|
||||
require_valid_protocol: true,
|
||||
},
|
||||
{ message: 'repoUrl must be a valid https:// or git:// URL' },
|
||||
)
|
||||
repoUrl?: string;
|
||||
}
|
||||
@@ -1,33 +1,11 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { WorkspaceService } from './workspace.service.js';
|
||||
|
||||
type ExecFileMock = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options: { cwd: string },
|
||||
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
||||
) => void;
|
||||
|
||||
const { execFileMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn<ExecFileMock>(),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
}));
|
||||
import path from 'node:path';
|
||||
|
||||
describe('WorkspaceService', () => {
|
||||
let service: WorkspaceService;
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset();
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
callback(null, '', '');
|
||||
});
|
||||
service = new WorkspaceService();
|
||||
});
|
||||
|
||||
@@ -98,69 +76,4 @@ describe('WorkspaceService', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const project = {
|
||||
id: 'project-1',
|
||||
ownerType: 'user',
|
||||
userId: 'user-1',
|
||||
teamId: null,
|
||||
} as const;
|
||||
|
||||
let originalRoot: string | undefined;
|
||||
let temporaryRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalRoot = process.env['MOSAIC_ROOT'];
|
||||
temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mosaic-workspace-'));
|
||||
process.env['MOSAIC_ROOT'] = temporaryRoot;
|
||||
service = new WorkspaceService();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalRoot === undefined) {
|
||||
delete process.env['MOSAIC_ROOT'];
|
||||
} else {
|
||||
process.env['MOSAIC_ROOT'] = originalRoot;
|
||||
}
|
||||
await fs.rm(temporaryRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a leading-dash URL', '--upload-pack=sh -c id'],
|
||||
['an ext remote helper', 'ext::sh -c id'],
|
||||
['a file URL', 'file:///tmp/repository'],
|
||||
['an unparseable value', 'not a url'],
|
||||
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
||||
['a scheme without //', 'https:example.com/acme/repository.git'],
|
||||
['a hostless git URL', 'git:///tmp/repository'],
|
||||
])('rejects %s before invoking git', async (_description, repoUrl) => {
|
||||
await expect(service.create(project, repoUrl)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(execFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an HTTPS URL', 'https://example.com/acme/repository.git'],
|
||||
['a git protocol URL', 'git://example.com/acme/repository.git'],
|
||||
])('accepts %s and invokes hardened git clone arguments', async (_description, repoUrl) => {
|
||||
const workspacePath = await service.create(project, repoUrl);
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledOnce();
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'protocol.ext.allow=never',
|
||||
'-c',
|
||||
'protocol.file.allow=never',
|
||||
'clone',
|
||||
'--',
|
||||
repoUrl,
|
||||
'.',
|
||||
],
|
||||
{ cwd: workspacePath },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,10 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const allowedRepositoryProtocols = new Set(['https:', 'git:']);
|
||||
const repositoryUrlPrefixPattern = /^(?:https|git):\/\//i;
|
||||
const repositoryUrlError = 'repoUrl must be a valid https:// or git:// URL';
|
||||
|
||||
function assertAllowedRepositoryUrl(repoUrl: string): void {
|
||||
if (repoUrl.startsWith('-') || !repositoryUrlPrefixPattern.test(repoUrl)) {
|
||||
throw new BadRequestException(repositoryUrlError);
|
||||
}
|
||||
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(repoUrl);
|
||||
} catch {
|
||||
throw new BadRequestException(repositoryUrlError);
|
||||
}
|
||||
|
||||
if (!allowedRepositoryProtocols.has(parsedUrl.protocol) || parsedUrl.hostname.length === 0) {
|
||||
throw new BadRequestException(repositoryUrlError);
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkspaceProject {
|
||||
id: string;
|
||||
@@ -59,32 +39,14 @@ export class WorkspaceService {
|
||||
* If repoUrl is provided, clone instead of init.
|
||||
*/
|
||||
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
||||
if (repoUrl !== undefined) {
|
||||
assertAllowedRepositoryUrl(repoUrl);
|
||||
}
|
||||
|
||||
const workspacePath = this.resolvePath(project);
|
||||
|
||||
// Create directory
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
if (repoUrl !== undefined) {
|
||||
// Clone existing repo. Defense in depth keeps dangerous local helpers
|
||||
// disabled and terminates option parsing before positional arguments.
|
||||
await execFileAsync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'protocol.ext.allow=never',
|
||||
'-c',
|
||||
'protocol.file.allow=never',
|
||||
'clone',
|
||||
'--',
|
||||
repoUrl,
|
||||
'.',
|
||||
],
|
||||
{ cwd: workspacePath },
|
||||
);
|
||||
if (repoUrl) {
|
||||
// Clone existing repo
|
||||
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
|
||||
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
||||
} else {
|
||||
// Init new git repo
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mosaic</title>
|
||||
<meta name="description" content="Mosaic Stack Dashboard" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
|
||||
/>
|
||||
<script>
|
||||
// set data-theme before first paint so the stored theme never flashes
|
||||
(function () {
|
||||
try {
|
||||
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
|
||||
} catch (error) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,25 +4,21 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build-web.mjs",
|
||||
"build:vite": "vite build",
|
||||
"dev": "next dev -p 3101",
|
||||
"dev:vite": "vite",
|
||||
"dev": "next dev",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:e2e": "playwright test",
|
||||
"start": "next start -p 3101"
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/design-tokens": "workspace:^",
|
||||
"@mosaicstack/types": "workspace:^",
|
||||
"better-auth": "^1.5.5",
|
||||
"clsx": "^2.1.0",
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
@@ -32,11 +28,9 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"jsdom": "^29.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,56 +3,41 @@
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
import { getSsoProvider } from '@/lib/sso-providers';
|
||||
|
||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||
const params = useParams<{ provider: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const provider = getSsoProvider(providerId);
|
||||
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const currentProvider = provider;
|
||||
|
||||
if (!currentProvider) {
|
||||
setError('Unknown SSO provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentProvider.enabled) {
|
||||
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeProvider = currentProvider;
|
||||
let cancelled = false;
|
||||
|
||||
async function redirectToProvider(): Promise<void> {
|
||||
try {
|
||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
||||
if (cancelled) return;
|
||||
const result = await signIn.oauth2({
|
||||
providerId: activeProvider.id,
|
||||
callbackURL,
|
||||
});
|
||||
|
||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||
if (!provider) {
|
||||
setError('Unknown SSO provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderName(provider.name);
|
||||
if (!provider.configured) {
|
||||
setError(`${provider.name} is not enabled in this deployment.`);
|
||||
return;
|
||||
}
|
||||
if (provider.loginMode !== 'oidc') {
|
||||
setError(`${provider.name} is not available for OIDC sign in.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signIn.oauth2({
|
||||
providerId: provider.id,
|
||||
callbackURL,
|
||||
});
|
||||
|
||||
if (!cancelled && result?.error) {
|
||||
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
||||
}
|
||||
if (!cancelled && result?.error) {
|
||||
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,22 +46,19 @@ export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [providerId, requestedCallbackURL]);
|
||||
}, [callbackURL, provider]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{providerName
|
||||
? `Redirecting you to ${providerName}...`
|
||||
{provider
|
||||
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
|
||||
: 'Preparing your sign-in request...'}
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
|
||||
<p>{error}</p>
|
||||
<Link
|
||||
href="/login"
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { api } from './api';
|
||||
|
||||
describe('api', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('fetches the supplied relative path with credentials and a JSON body', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
api<{ ok: boolean }>('/api/projects', {
|
||||
method: 'POST',
|
||||
body: { name: 'Mosaic' },
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/projects',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name: 'Mosaic' }),
|
||||
headers: expect.objectContaining({
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws the gateway JSON error with its statusCode', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ statusCode: 403, message: 'Forbidden' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(api('/api/admin/users')).rejects.toMatchObject({
|
||||
name: 'Error',
|
||||
message: 'Forbidden',
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
||||
|
||||
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
}
|
||||
@@ -23,7 +25,7 @@ export async function api<T>(path: string, init?: ApiRequestInit): Promise<T> {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(path, {
|
||||
const res = await fetch(`${GATEWAY_URL}${path}`, {
|
||||
credentials: 'include',
|
||||
...rest,
|
||||
headers,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('auth client origin contract', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('uses the same-origin BetterAuth mount at /api/auth', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ session: null, user: null }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { authClient } = await import('./auth-client');
|
||||
await authClient.getSession();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const firstCall = fetchMock.mock.calls.at(0);
|
||||
expect(firstCall).toBeDefined();
|
||||
const requestURL = new URL(String(firstCall?.[0]), window.location.origin);
|
||||
expect(requestURL.origin).toBe(window.location.origin);
|
||||
expect(requestURL.pathname).toBe('/api/auth/get-session');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createAuthClient } from 'better-auth/react';
|
||||
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
||||
|
||||
// The gateway and BetterAuth client both use /api/auth. Omitting baseURL keeps
|
||||
// every browser request on the current origin in development and production.
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242',
|
||||
plugins: [adminClient(), genericOAuthClient()],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveAuthCallbackURL } from './auth-redirect';
|
||||
|
||||
const CURRENT_ORIGIN = 'https://mosaic.example';
|
||||
|
||||
describe('resolveAuthCallbackURL', () => {
|
||||
it('preserves a canonical same-origin path with search and hash', () => {
|
||||
expect(resolveAuthCallbackURL('/projects?view=active#current', CURRENT_ORIGIN)).toBe(
|
||||
'/projects?view=active#current',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
'chat',
|
||||
'//evil.example',
|
||||
'/..//evil.com',
|
||||
'/..//evil.com/x',
|
||||
'/./..//evil.com',
|
||||
'/../..//evil.com',
|
||||
'/foo/..//evil.com',
|
||||
'/\\evil.example',
|
||||
'/\n//evil.example',
|
||||
'/\r//evil.example',
|
||||
'/\t//evil.example',
|
||||
'https://evil.example/phish',
|
||||
])('falls back to chat for an unsafe callback target %#', (candidate) => {
|
||||
expect(resolveAuthCallbackURL(candidate, CURRENT_ORIGIN)).toBe('/chat');
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
const DEFAULT_AUTH_CALLBACK_URL = '/chat';
|
||||
|
||||
/**
|
||||
* Return a canonical same-origin path for post-auth navigation.
|
||||
*
|
||||
* Parsing before comparing origins rejects protocol-relative URLs, backslash
|
||||
* variants, and control characters that the WHATWG parser normalizes away.
|
||||
*/
|
||||
export function resolveAuthCallbackURL(candidate: string | null, currentOrigin: string): string {
|
||||
if (!candidate?.startsWith('/')) return DEFAULT_AUTH_CALLBACK_URL;
|
||||
|
||||
try {
|
||||
const expectedOrigin = new URL(currentOrigin).origin;
|
||||
const resolved = new URL(candidate, expectedOrigin);
|
||||
if (resolved.origin !== expectedOrigin || resolved.pathname.startsWith('//')) {
|
||||
return DEFAULT_AUTH_CALLBACK_URL;
|
||||
}
|
||||
|
||||
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
||||
} catch {
|
||||
return DEFAULT_AUTH_CALLBACK_URL;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Centralizes the type-only import of the shared `/chat` Socket.IO contract from
|
||||
// the public `@mosaicstack/types` package. `import type` is erased at compile
|
||||
// time, so this introduces no runtime dependency — it only reuses the exact
|
||||
// payload shapes instead of redeclaring them.
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import type {
|
||||
AbortPayload,
|
||||
AgentEndPayload,
|
||||
AgentStartPayload,
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ChatMessagePayload,
|
||||
ClientToServerEvents,
|
||||
CommandDef,
|
||||
CommandManifest,
|
||||
CommandManifestPayload,
|
||||
ErrorPayload,
|
||||
MessageAckPayload,
|
||||
RoutingDecisionInfo,
|
||||
ServerToClientEvents,
|
||||
SessionInfoPayload,
|
||||
SessionUsagePayload,
|
||||
SetThinkingPayload,
|
||||
SkillCommandDef,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
ToolEndPayload,
|
||||
ToolStartPayload,
|
||||
} from '@mosaicstack/types';
|
||||
|
||||
export type {
|
||||
AbortPayload,
|
||||
AgentEndPayload,
|
||||
AgentStartPayload,
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ChatMessagePayload,
|
||||
ClientToServerEvents,
|
||||
CommandDef,
|
||||
CommandManifest,
|
||||
CommandManifestPayload,
|
||||
ErrorPayload,
|
||||
MessageAckPayload,
|
||||
RoutingDecisionInfo,
|
||||
ServerToClientEvents,
|
||||
SessionInfoPayload,
|
||||
SessionUsagePayload,
|
||||
SetThinkingPayload,
|
||||
SkillCommandDef,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
ToolEndPayload,
|
||||
ToolStartPayload,
|
||||
};
|
||||
|
||||
/** The `/chat` namespace socket, narrowed to the exact typed event contract. */
|
||||
export type ChatSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||
@@ -1,98 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { ioMock } = vi.hoisted(() => ({
|
||||
ioMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('socket.io-client', () => ({
|
||||
io: ioMock,
|
||||
}));
|
||||
|
||||
import { destroySocket, getSocket } from './socket';
|
||||
|
||||
interface MockChatSocket {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
offAny: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
/** Test-only helper: fires every handler registered for `event` via
|
||||
* `.on`, mirroring how a real socket.io-client instance invokes its own
|
||||
* listeners (e.g. calling the registered `disconnect` handler(s) on a
|
||||
* real transient disconnect). */
|
||||
trigger(event: string): void;
|
||||
}
|
||||
|
||||
function createMockSocket(): MockChatSocket {
|
||||
const handlers = new Map<string, Set<() => void>>();
|
||||
const mockSocket: MockChatSocket = {
|
||||
on: vi.fn((event: string, handler: () => void) => {
|
||||
if (!handlers.has(event)) handlers.set(event, new Set());
|
||||
handlers.get(event)?.add(handler);
|
||||
return mockSocket;
|
||||
}),
|
||||
offAny: vi.fn(() => mockSocket),
|
||||
disconnect: vi.fn(() => mockSocket),
|
||||
trigger(event: string): void {
|
||||
for (const handler of handlers.get(event) ?? []) handler();
|
||||
},
|
||||
};
|
||||
return mockSocket;
|
||||
}
|
||||
|
||||
let currentMock!: MockChatSocket;
|
||||
|
||||
describe('chat socket', () => {
|
||||
beforeEach(() => {
|
||||
ioMock.mockReset();
|
||||
// A fresh object per io() call so identity assertions (same singleton vs.
|
||||
// a genuinely new instance) are meaningful.
|
||||
ioMock.mockImplementation(() => {
|
||||
currentMock = createMockSocket();
|
||||
return currentMock;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroySocket();
|
||||
});
|
||||
|
||||
it('creates one same-origin /chat namespace socket', () => {
|
||||
const first = getSocket();
|
||||
const second = getSocket();
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(ioMock).toHaveBeenCalledOnce();
|
||||
expect(ioMock).toHaveBeenCalledWith('/chat', {
|
||||
withCredentials: true,
|
||||
autoConnect: false,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the same singleton instance across a transient disconnect', () => {
|
||||
const first = getSocket();
|
||||
|
||||
// socket.ts must not react to a real socket's `disconnect` event by
|
||||
// nulling the singleton — it registers no such handler at all now.
|
||||
// Actually fire every handler registered via `.on('disconnect', ...)`
|
||||
// (mirroring a real socket.io-client reconnect) instead of merely
|
||||
// calling getSocket() again: this is what makes the test fail if
|
||||
// production reintroduces `socket.on('disconnect', () => { socket =
|
||||
// null; })`, since that handler would run here and null the singleton
|
||||
// before the next getSocket() call.
|
||||
currentMock.trigger('disconnect');
|
||||
const second = getSocket();
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(ioMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('only creates a new singleton after an explicit destroySocket()', () => {
|
||||
const first = getSocket();
|
||||
|
||||
destroySocket();
|
||||
const second = getSocket();
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(ioMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
+13
-17
@@ -1,27 +1,23 @@
|
||||
import { io } from 'socket.io-client';
|
||||
import type { ChatSocket } from './chat-contract';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
|
||||
let socket: ChatSocket | null = null;
|
||||
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
||||
|
||||
export function getSocket(): ChatSocket {
|
||||
let socket: Socket | null = null;
|
||||
|
||||
export function getSocket(): Socket {
|
||||
if (!socket) {
|
||||
// socket.io-client 4.8.3's `io()` factory declaration always returns the
|
||||
// default unparameterized Socket (it accepts no <ListenEvents, EmitEvents>
|
||||
// generics), so this one cast is the unavoidable boundary between that and the
|
||||
// typed `/chat` contract. Every other call site uses the resulting ChatSocket
|
||||
// with no further assertions.
|
||||
socket = io('/chat', {
|
||||
socket = io(`${GATEWAY_URL}/chat`, {
|
||||
withCredentials: true,
|
||||
autoConnect: false,
|
||||
transports: ['websocket', 'polling'],
|
||||
}) as unknown as ChatSocket;
|
||||
});
|
||||
|
||||
// A transient `disconnect` (network blip, server restart) must NOT null
|
||||
// the singleton: socket.io-client auto-reconnects this same instance,
|
||||
// and its listeners stay registered across that reconnect. Nulling here
|
||||
// previously orphaned those listeners on the next getSocket() call by
|
||||
// handing back a brand-new, unconnected instance. Only destroySocket()
|
||||
// (an explicit, intentional teardown) may reset the singleton.
|
||||
// Reset singleton reference when socket is fully closed so the next
|
||||
// getSocket() call creates a fresh instance instead of returning a
|
||||
// closed/dead socket.
|
||||
socket.on('disconnect', () => {
|
||||
socket = null;
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
|
||||
|
||||
describe('sso-providers', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('returns the enabled providers in login button order', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
|
||||
|
||||
expect(getEnabledSsoProviders()).toEqual([
|
||||
{
|
||||
id: 'workos',
|
||||
buttonLabel: 'Continue with WorkOS',
|
||||
description: 'Enterprise SSO via WorkOS',
|
||||
enabled: true,
|
||||
href: '/auth/provider/workos',
|
||||
},
|
||||
{
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
enabled: true,
|
||||
href: '/auth/provider/keycloak',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks disabled providers without exposing them in the enabled list', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
|
||||
|
||||
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
|
||||
expect(getSsoProvider('keycloak')).toEqual({
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
enabled: false,
|
||||
href: '/auth/provider/keycloak',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unknown providers', () => {
|
||||
expect(getSsoProvider('authentik')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
export type SsoProviderId = 'workos' | 'keycloak';
|
||||
|
||||
export interface SsoProvider {
|
||||
id: SsoProviderId;
|
||||
buttonLabel: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
|
||||
workos: {
|
||||
id: 'workos',
|
||||
buttonLabel: 'Continue with WorkOS',
|
||||
description: 'Enterprise SSO via WorkOS',
|
||||
},
|
||||
keycloak: {
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
},
|
||||
};
|
||||
|
||||
export function getEnabledSsoProviders(): SsoProvider[] {
|
||||
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
|
||||
.map((providerId) => getSsoProvider(providerId))
|
||||
.filter((provider): provider is SsoProvider => provider?.enabled === true);
|
||||
}
|
||||
|
||||
export function getSsoProvider(providerId: string): SsoProvider | null {
|
||||
if (!isSsoProviderId(providerId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...PROVIDER_METADATA[providerId],
|
||||
enabled: isSsoProviderEnabled(providerId),
|
||||
href: `/auth/provider/${providerId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function isSsoProviderId(value: string): value is SsoProviderId {
|
||||
return value === 'workos' || value === 'keycloak';
|
||||
}
|
||||
|
||||
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
|
||||
switch (providerId) {
|
||||
case 'workos':
|
||||
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
|
||||
case 'keycloak':
|
||||
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import { createAppRouter } from '@/routes';
|
||||
import '@/app/globals.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
throw new Error('missing #root element');
|
||||
}
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={createAppRouter()} />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-router-dom';
|
||||
import { LoginPage } from '@/spa/pages/login';
|
||||
import { RegisterPage } from '@/spa/pages/register';
|
||||
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
||||
import { ChatPage } from '@/spa/pages/chat';
|
||||
import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary';
|
||||
import { ProjectDetailPage } from '@/spa/pages/project-detail';
|
||||
import { ProjectsPage } from '@/spa/pages/projects';
|
||||
import {
|
||||
ProjectDetailRouteErrorBoundary,
|
||||
ProjectsRouteErrorBoundary,
|
||||
TasksRouteErrorBoundary,
|
||||
} from '@/spa/pages/resource-route-error-boundaries';
|
||||
import { TasksPage } from '@/spa/pages/tasks';
|
||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||
import { Placeholder } from '@/spa/placeholder';
|
||||
|
||||
function GuestLayout(): ReactElement {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-surface-bg px-4 py-8">
|
||||
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
element: <GuestGuard />,
|
||||
children: [
|
||||
{
|
||||
element: <GuestLayout />,
|
||||
children: [
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/register', element: <RegisterPage /> },
|
||||
{ path: '/auth/provider/:provider', element: <SsoCallbackPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||
{
|
||||
path: '/projects',
|
||||
element: <ProjectsPage />,
|
||||
errorElement: <ProjectsRouteErrorBoundary />,
|
||||
},
|
||||
{
|
||||
path: '/projects/:id',
|
||||
element: <ProjectDetailPage />,
|
||||
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||
},
|
||||
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function createAppRouter(): ReturnType<typeof createBrowserRouter> {
|
||||
return createBrowserRouter(routes);
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { CommandsPanel } from './commands-panel';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
let root: Root | null;
|
||||
let container: HTMLElement | null;
|
||||
|
||||
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(node);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('CommandsPanel', () => {
|
||||
it('shows the frozen local pendingApproval args in the confirmation area, regardless of misleading server message text', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{
|
||||
conversationId: 'c1',
|
||||
command: 'deploy', // matches pendingApproval — this is a legitimately approved request
|
||||
success: true,
|
||||
approvalId: 'ap1',
|
||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||
// Free-text server message claims a different, less alarming target
|
||||
// than what will actually be sent — the UI must not rely on this.
|
||||
message: 'This will only affect the staging environment.',
|
||||
}}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The exact frozen combined action is visible...
|
||||
expect(container?.textContent).toContain('/deploy');
|
||||
expect(container?.textContent).toContain('prod');
|
||||
// ...and the misleading server free-text is never shown next to it.
|
||||
expect(container?.textContent).not.toContain('staging environment');
|
||||
});
|
||||
|
||||
it('does not throw when a manifest commands entry is null', async () => {
|
||||
const manifest = {
|
||||
commands: [
|
||||
null,
|
||||
{
|
||||
name: 'model',
|
||||
aliases: [],
|
||||
description: 'Change the active model',
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
skills: [null],
|
||||
version: 1,
|
||||
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
||||
|
||||
await expect(
|
||||
render(
|
||||
<CommandsPanel
|
||||
manifest={manifest}
|
||||
results={[]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(container?.textContent).toContain('model');
|
||||
});
|
||||
|
||||
it('shows an explicit no-args fallback when the frozen pendingApproval has no args', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: true,
|
||||
approvalId: 'ap1',
|
||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||
}}
|
||||
pendingApproval={{ command: 'deploy' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent?.toLowerCase()).toContain('no args');
|
||||
});
|
||||
|
||||
it('renders skills from a skills-only manifest', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={{
|
||||
commands: [],
|
||||
skills: [{ name: 'brave-search', description: 'Search the web', available: true }],
|
||||
version: 1,
|
||||
}}
|
||||
results={[]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('brave-search');
|
||||
expect(container?.textContent).toContain('Search the web');
|
||||
});
|
||||
|
||||
it('does not show the Run affordance when approval.success/approvalId are objects, even though command matches pendingApproval', async () => {
|
||||
const approval = {
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: { truthy: 'object' },
|
||||
approvalId: { also: 'object' },
|
||||
} as unknown as Parameters<typeof CommandsPanel>[0]['approval'];
|
||||
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={approval}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
[...(container?.querySelectorAll('button') ?? [])].some((button) =>
|
||||
button.textContent?.includes('Run approved command'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the guarded server-provided denial reason for a denied approval', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: false,
|
||||
message: 'Not authorized',
|
||||
}}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('Not authorized');
|
||||
});
|
||||
|
||||
it('falls back to a stable "Denied." copy when a denial has no usable message', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{ conversationId: 'c1', command: 'deploy', success: false }}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('Denied.');
|
||||
});
|
||||
|
||||
it('shows the guarded contract-provided reason for a failed command result, falling back to a stable copy only when absent', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[
|
||||
{ conversationId: 'c1', command: 'model', success: false, message: 'Unknown model' },
|
||||
{ conversationId: 'c1', command: 'deploy', success: false },
|
||||
]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('Unknown model');
|
||||
expect(container?.textContent).toContain('Command failed.');
|
||||
});
|
||||
|
||||
it('bounds an oversized command result message at the render site as defense-in-depth', async () => {
|
||||
const hostileMessage = 'y'.repeat(50_000);
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[
|
||||
{ conversationId: 'c1', command: 'model', success: false, message: hostileMessage },
|
||||
]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const text = container?.textContent ?? '';
|
||||
expect(text.length).toBeLessThan(hostileMessage.length);
|
||||
});
|
||||
|
||||
it('does not throw when the manifest fields are malformed (non-array commands/skills)', async () => {
|
||||
const manifest = {
|
||||
commands: 'not-an-array',
|
||||
skills: null,
|
||||
version: 1,
|
||||
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
||||
|
||||
await expect(
|
||||
render(
|
||||
<CommandsPanel
|
||||
manifest={manifest}
|
||||
results={[]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useState, type ReactElement } from 'react';
|
||||
import type { PendingApproval } from './use-chat-connection';
|
||||
import { MAX_COMMAND_MESSAGE_CHARS } from './limits';
|
||||
import { asNonEmptyString, asString } from './runtime-guards';
|
||||
import type {
|
||||
CommandManifest,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandResultPayload,
|
||||
} from '@/lib/chat-contract';
|
||||
|
||||
/** Stable fallback copy shown for a failed command only when the server's
|
||||
* own guarded, non-empty `message` (e.g. "Unknown model") is absent or
|
||||
* malformed — the structured contract reason itself is otherwise shown
|
||||
* directly, never a raw thrown exception, stack trace, or object value. */
|
||||
const COMMAND_FAILURE_COPY = 'Command failed.';
|
||||
|
||||
/** Render-site defense-in-depth: `use-chat-connection.ts` already bounds a
|
||||
* stored command:result message at ingestion, but this component must never
|
||||
* assume every caller went through that path — bounding again here means a
|
||||
* hostile/oversized message can never force an unbounded render. */
|
||||
function boundMessage(value: string): string {
|
||||
return value.length > MAX_COMMAND_MESSAGE_CHARS
|
||||
? value.slice(0, MAX_COMMAND_MESSAGE_CHARS)
|
||||
: value;
|
||||
}
|
||||
|
||||
interface CommandsPanelProps {
|
||||
manifest: CommandManifest | null;
|
||||
results: SlashCommandResultPayload[];
|
||||
approval: SlashCommandApprovalResultPayload | null;
|
||||
pendingApproval: PendingApproval | null;
|
||||
hasConversation: boolean;
|
||||
onExecute: (input: { command: string; args?: string }) => void;
|
||||
onApprove: (input: { command: string; args?: string }) => void;
|
||||
onRunApproved: () => void;
|
||||
}
|
||||
|
||||
export function CommandsPanel({
|
||||
manifest,
|
||||
results,
|
||||
approval,
|
||||
pendingApproval,
|
||||
hasConversation,
|
||||
onExecute,
|
||||
onApprove,
|
||||
onRunApproved,
|
||||
}: CommandsPanelProps): ReactElement {
|
||||
const [command, setCommand] = useState('');
|
||||
const [args, setArgs] = useState('');
|
||||
|
||||
// Defense-in-depth: the reducer already normalizes success/approvalId
|
||||
// before storing `approval`, but a matching command string alone must
|
||||
// never be trusted here either — require the literal boolean `true` and a
|
||||
// non-empty string approvalId, not merely truthy values.
|
||||
const canRunApproved =
|
||||
approval?.success === true &&
|
||||
typeof approval.approvalId === 'string' &&
|
||||
approval.approvalId.length > 0 &&
|
||||
!!pendingApproval &&
|
||||
pendingApproval.command === approval.command;
|
||||
|
||||
// A manifest arrives from the server as untyped JSON at runtime — guard
|
||||
// both collections before mapping so a malformed manifest cannot throw.
|
||||
const commands = Array.isArray(manifest?.commands) ? manifest.commands : [];
|
||||
const skills = Array.isArray(manifest?.skills) ? manifest.skills : [];
|
||||
|
||||
return (
|
||||
<section aria-label="Commands" className="flex flex-col gap-2 border-b px-4 py-3 text-xs">
|
||||
{commands.length > 0 ? (
|
||||
<ul aria-label="Available commands" className="flex flex-col gap-1">
|
||||
{commands.map((cmd, index) => (
|
||||
<li key={asString(cmd?.name) || `cmd-${index}`}>
|
||||
<strong>/{asString(cmd?.name)}</strong> — {asString(cmd?.description)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{skills.length > 0 ? (
|
||||
<ul aria-label="Available skills" className="flex flex-col gap-1">
|
||||
{skills.map((skill, index) => (
|
||||
<li key={asString(skill?.name) || `skill-${index}`}>
|
||||
<strong>/skill:{asString(skill?.name)}</strong> — {asString(skill?.description)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
aria-label="Command name"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
placeholder="command"
|
||||
/>
|
||||
<input
|
||||
aria-label="Command arguments"
|
||||
value={args}
|
||||
onChange={(event) => setArgs(event.target.value)}
|
||||
placeholder="args (optional)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasConversation || !command.trim()}
|
||||
onClick={() => onExecute({ command: command.trim(), args: args.trim() || undefined })}
|
||||
>
|
||||
Run command
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasConversation || !command.trim()}
|
||||
onClick={() => onApprove({ command: command.trim(), args: args.trim() || undefined })}
|
||||
>
|
||||
Request approval
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{approval ? (
|
||||
<div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2">
|
||||
{/* A successful approval shows stable client copy only — never
|
||||
the server-controlled approval.message or echoed
|
||||
approval.command as the primary confirmation. The frozen local
|
||||
pendingApproval below (not this line) is the sole authoritative
|
||||
statement of what will run. A denial, by contrast, is not an
|
||||
execution authority and safely surfaces the guarded structured
|
||||
reason the server gave (e.g. "Not authorized"), falling back to
|
||||
a stable copy only when absent/malformed. */}
|
||||
<span>
|
||||
{approval.success ? 'Approved.' : asNonEmptyString(approval.message, 'Denied.')}
|
||||
</span>
|
||||
{canRunApproved && pendingApproval ? (
|
||||
<>
|
||||
{/* Authoritative frozen local command+args — what the click below
|
||||
will actually emit. The server's `approval` above is display-only
|
||||
and must never be trusted to represent the executed payload. */}
|
||||
<span>
|
||||
Will run: /{pendingApproval.command}{' '}
|
||||
{pendingApproval.args ? pendingApproval.args : '(no args)'}
|
||||
</span>
|
||||
<button type="button" onClick={onRunApproved}>
|
||||
Run approved command
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results.length > 0 ? (
|
||||
<ul aria-label="Command results" className="flex flex-col gap-1">
|
||||
{results.map((result, index) => (
|
||||
<li key={`${result.command}-${index}`} role={result.success ? 'status' : 'alert'}>
|
||||
/{asString(result.command)}: {result.success ? 'success' : 'failed'}
|
||||
{result.success
|
||||
? typeof result.message === 'string' && result.message
|
||||
? ` — ${boundMessage(result.message)}`
|
||||
: ''
|
||||
: ` — ${boundMessage(asNonEmptyString(result.message, COMMAND_FAILURE_COPY))}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
||||
|
||||
interface ComposerProps {
|
||||
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
||||
onStop: () => void;
|
||||
streaming: boolean;
|
||||
/** True from local send time through server turn startup/ack and
|
||||
* throughout streaming — a superset of `streaming` that also covers the
|
||||
* pre-ack window where a second send could otherwise slip through. */
|
||||
sending: boolean;
|
||||
hasConversation: boolean;
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
onSend,
|
||||
onStop,
|
||||
streaming,
|
||||
sending,
|
||||
hasConversation,
|
||||
}: ComposerProps): ReactElement {
|
||||
const [content, setContent] = useState('');
|
||||
const [provider, setProvider] = useState('');
|
||||
const [modelId, setModelId] = useState('');
|
||||
const busy = streaming || sending;
|
||||
|
||||
function submit(): void {
|
||||
if (busy) return;
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return;
|
||||
onSend({
|
||||
content: trimmed,
|
||||
provider: provider.trim() || undefined,
|
||||
modelId: modelId.trim() || undefined,
|
||||
});
|
||||
setContent('');
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
className="flex flex-col gap-2 border-t p-4"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
aria-label="Provider"
|
||||
value={provider}
|
||||
onChange={(event) => setProvider(event.target.value)}
|
||||
placeholder="Provider (optional)"
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
/>
|
||||
<input
|
||||
aria-label="Model"
|
||||
value={modelId}
|
||||
onChange={(event) => setModelId(event.target.value)}
|
||||
placeholder="Model (optional)"
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
aria-label="Message"
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={2}
|
||||
placeholder="Message… (Enter to send, Shift+Enter for a new line)"
|
||||
className="flex-1 resize-none rounded border px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!content.trim() || busy}
|
||||
className="rounded px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Stop"
|
||||
disabled={!hasConversation || !streaming}
|
||||
onClick={onStop}
|
||||
className="rounded px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Bounds on server-fed chat state. A hostile or malfunctioning gateway can
|
||||
* flood any of these collections; caps keep memory/render cost flat instead
|
||||
* of growing unboundedly for the lifetime of the connection.
|
||||
*/
|
||||
|
||||
/** Max characters retained for the in-flight streamed text/thinking buffers. */
|
||||
export const MAX_STREAM_CHARS = 20_000;
|
||||
/** Max transcript turns retained (oldest dropped first). */
|
||||
export const MAX_MESSAGES = 500;
|
||||
/** Max tool-call entries (including anomaly entries) retained per turn history. */
|
||||
export const MAX_TOOLS = 200;
|
||||
/** Max slash-command results retained. */
|
||||
export const MAX_COMMAND_RESULTS = 200;
|
||||
/** Max commands/skills accepted from a single manifest push. */
|
||||
export const MAX_MANIFEST_ITEMS = 500;
|
||||
/** Max executed approval IDs remembered for single-flight dedup. */
|
||||
export const MAX_EXECUTED_APPROVAL_IDS = 200;
|
||||
/** Max characters retained for a single command:result message — a hostile
|
||||
* or malfunctioning gateway must not be able to push an unbounded curated
|
||||
* success/failure reason into state (or, defensively, onto the page). */
|
||||
export const MAX_COMMAND_MESSAGE_CHARS = 1_000;
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import type { ChatTranscriptMessage } from './use-chat-connection';
|
||||
|
||||
interface MessageTranscriptProps {
|
||||
messages: ChatTranscriptMessage[];
|
||||
streaming: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function MessageTranscript({
|
||||
messages,
|
||||
streaming,
|
||||
text,
|
||||
}: MessageTranscriptProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label="Conversation"
|
||||
className="flex flex-1 flex-col gap-3 overflow-y-auto p-4"
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} data-role={message.role} className="whitespace-pre-wrap text-sm">
|
||||
<span className="font-medium">{message.role === 'user' ? 'You' : 'Assistant'}: </span>
|
||||
<span>{message.text}</span>
|
||||
{message.thinking ? (
|
||||
<div className="pt-1 text-xs italic opacity-70">{message.thinking}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{streaming ? (
|
||||
<div data-role="assistant-streaming" className="whitespace-pre-wrap text-sm">
|
||||
<span className="font-medium">Assistant: </span>
|
||||
<span>{text || 'Thinking…'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Socket.IO payloads are only statically typed at the call site — a
|
||||
* misbehaving or compromised gateway can send anything at runtime. These
|
||||
* guards protect the dereference sites that would otherwise throw (`.map` on
|
||||
* a non-array, `.toFixed` on a non-number) or render an object as a React
|
||||
* child.
|
||||
*/
|
||||
|
||||
export function asString(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
/** Like `asString`, but an empty string also falls back — used for guarded
|
||||
* contract-provided reason strings (e.g. a denial or failure message) where
|
||||
* an empty string is not a meaningful value to display in place of the
|
||||
* stable fallback copy. */
|
||||
export function asNonEmptyString(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
export function asFiniteNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
/** Like `asFiniteNumber`, but returns `null` on failure instead of a numeric
|
||||
* fallback — callers that must not fabricate a plausible-looking value (e.g.
|
||||
* `0 tokens` / `$0.0000` for genuinely unknown usage) use this to render an
|
||||
* honest "unavailable" label instead. */
|
||||
export function asFiniteNumberOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : [];
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/** The single point of truth for what counts as a valid conversation ID
|
||||
* anywhere a scoped server event may adopt one into state — a non-empty
|
||||
* string, nothing else. Every site that establishes or compares
|
||||
* `state.conversationId` against a raw socket payload must route through
|
||||
* this guard so a malformed first frame (null/object/number/empty string)
|
||||
* can never be adopted verbatim. */
|
||||
export function asConversationId(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import type { SessionInfoPayload } from '@/lib/chat-contract';
|
||||
import { MAX_MANIFEST_ITEMS } from './limits';
|
||||
import { asString, asStringArray } from './runtime-guards';
|
||||
|
||||
interface SessionPanelProps {
|
||||
sessionInfo: SessionInfoPayload | null;
|
||||
onSetThinking: (level: string) => void;
|
||||
}
|
||||
|
||||
const THINKING_LEVEL_UNAVAILABLE = '';
|
||||
|
||||
export function SessionPanel({
|
||||
sessionInfo,
|
||||
onSetThinking,
|
||||
}: SessionPanelProps): ReactElement | null {
|
||||
if (!sessionInfo) return null;
|
||||
|
||||
// The reducer already caps this before storing it, but the render site
|
||||
// defends independently — a hostile payload must never be able to force
|
||||
// this <select> to lay out an unbounded number of options.
|
||||
const availableThinkingLevels = asStringArray(sessionInfo.availableThinkingLevels).slice(
|
||||
0,
|
||||
MAX_MANIFEST_ITEMS,
|
||||
);
|
||||
const hasThinkingLevels = availableThinkingLevels.length > 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Session info"
|
||||
className="flex flex-wrap items-center gap-3 border-b px-4 py-2 text-xs"
|
||||
>
|
||||
<span>{asString(sessionInfo.provider, 'unknown')}</span>
|
||||
<span>{asString(sessionInfo.modelId, 'unknown')}</span>
|
||||
<label className="flex items-center gap-2">
|
||||
<span>Thinking level</span>
|
||||
<select
|
||||
aria-label="Thinking level"
|
||||
value={
|
||||
hasThinkingLevels ? asString(sessionInfo.thinkingLevel) : THINKING_LEVEL_UNAVAILABLE
|
||||
}
|
||||
onChange={(event) => {
|
||||
// The placeholder option is not a real, settable level — a
|
||||
// malformed availableThinkingLevels list must never let the
|
||||
// client emit set:thinking for it.
|
||||
if (!hasThinkingLevels) return;
|
||||
onSetThinking(event.target.value);
|
||||
}}
|
||||
>
|
||||
{hasThinkingLevels ? (
|
||||
availableThinkingLevels.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{level}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value={THINKING_LEVEL_UNAVAILABLE}>Thinking level unavailable</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{sessionInfo.routingDecision ? (
|
||||
<span title={asString(sessionInfo.routingDecision.ruleName)}>
|
||||
{asString(sessionInfo.routingDecision.reason)}
|
||||
</span>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { ClientToServerEvents, ServerToClientEvents } from '@/lib/chat-contract';
|
||||
|
||||
type ServerEvent = keyof ServerToClientEvents;
|
||||
type ClientEvent = keyof ClientToServerEvents;
|
||||
type ServerHandler<K extends ServerEvent> = ServerToClientEvents[K];
|
||||
type ClientPayload<K extends ClientEvent> = Parameters<ClientToServerEvents[K]>[0];
|
||||
|
||||
export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
|
||||
event: K;
|
||||
payload: ClientPayload<K>;
|
||||
}
|
||||
|
||||
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
|
||||
export interface FakeChatSocket {
|
||||
connected: boolean;
|
||||
connect(): FakeChatSocket;
|
||||
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||
emit<K extends ClientEvent>(event: K, payload: ClientPayload<K>): FakeChatSocket;
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed in-memory stand-in for `getSocket()`. Unlike a bare
|
||||
* `(event: string, payload: unknown) => void` mock, every public method here is
|
||||
* checked against the real `/chat` contract — a typo'd event name or a payload
|
||||
* missing a required field fails to compile instead of silently no-op'ing at
|
||||
* runtime.
|
||||
*/
|
||||
/** Socket.IO's built-in connection-state events. Not part of the app-level
|
||||
* ServerToClientEvents contract, but real sockets always support them and
|
||||
* `useChatConnection` registers a `disconnect` handler on the real socket. */
|
||||
type LifecycleEvent = 'connect' | 'disconnect';
|
||||
|
||||
export function createFakeChatSocket(): {
|
||||
socket: FakeChatSocket;
|
||||
listeners: Map<ServerEvent, Set<(payload: never) => void>>;
|
||||
emitted: EmittedEvent[];
|
||||
serverEmit<K extends ServerEvent>(
|
||||
event: K,
|
||||
payload: Parameters<ServerToClientEvents[K]>[0],
|
||||
): void;
|
||||
/** Escape hatch for malformed-payload tests: bypasses the compile-time
|
||||
* payload contract to simulate a genuinely untrusted runtime value from the
|
||||
* server, e.g. a `session:info` with a non-array `availableThinkingLevels`. */
|
||||
serverEmitRaw(event: ServerEvent, payload: unknown): void;
|
||||
/** Simulates a transient Socket.IO `disconnect` — fires any handler(s)
|
||||
* registered via `socket.on('disconnect', ...)` without clearing any
|
||||
* listeners, mirroring how a real reconnecting socket behaves. */
|
||||
simulateDisconnect(): void;
|
||||
/** Simulates socket.io-client's automatic reconnect of the *same*
|
||||
* instance after a transient disconnect: marks the socket connected again
|
||||
* and fires any handler(s) registered via `socket.on('connect', ...)`,
|
||||
* without clearing or replacing any listeners. */
|
||||
simulateReconnect(): void;
|
||||
} {
|
||||
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
||||
const emitted: EmittedEvent[] = [];
|
||||
|
||||
// Internal storage is intentionally keyed loosely (the per-event handler shape
|
||||
// varies by K, which a single Map can't express); the generic signatures on the
|
||||
// exported `socket`/`serverEmit` above and below are what keep test call sites
|
||||
// type-checked against ServerToClientEvents/ClientToServerEvents.
|
||||
const socket = {
|
||||
connected: false,
|
||||
connect: vi.fn(function connect(this: void) {
|
||||
socket.connected = true;
|
||||
return socket;
|
||||
}),
|
||||
on: vi.fn(function on(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
||||
if (!listeners.has(event)) listeners.set(event, new Set());
|
||||
listeners.get(event)?.add(handler);
|
||||
return socket;
|
||||
}),
|
||||
off: vi.fn(function off(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
||||
listeners.get(event)?.delete(handler);
|
||||
return socket;
|
||||
}),
|
||||
emit: vi.fn(function emit(this: void, event: ClientEvent, payload: unknown) {
|
||||
emitted.push({ event, payload } as EmittedEvent);
|
||||
return socket;
|
||||
}),
|
||||
} as unknown as FakeChatSocket;
|
||||
|
||||
function serverEmit<K extends ServerEvent>(
|
||||
event: K,
|
||||
payload: Parameters<ServerToClientEvents[K]>[0],
|
||||
): void {
|
||||
for (const handler of listeners.get(event) ?? []) {
|
||||
(handler as (payload: Parameters<ServerToClientEvents[K]>[0]) => void)(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function serverEmitRaw(event: ServerEvent, payload: unknown): void {
|
||||
for (const handler of listeners.get(event) ?? []) {
|
||||
(handler as (payload: unknown) => void)(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function simulateDisconnect(): void {
|
||||
socket.connected = false;
|
||||
const lifecycleKey = 'disconnect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||
(handler as () => void)();
|
||||
}
|
||||
}
|
||||
|
||||
function simulateReconnect(): void {
|
||||
socket.connected = true;
|
||||
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||
(handler as () => void)();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
socket,
|
||||
listeners,
|
||||
emitted,
|
||||
serverEmit,
|
||||
serverEmitRaw,
|
||||
simulateDisconnect,
|
||||
simulateReconnect,
|
||||
};
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { ToolCallList } from './tool-call-list';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
let root: Root | null;
|
||||
let container: HTMLElement | null;
|
||||
|
||||
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(node);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('ToolCallList', () => {
|
||||
it('renders two entries independently, without a duplicate-key warning, when a valid toolCallId is shared', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await render(
|
||||
<ToolCallList
|
||||
tools={[
|
||||
{ toolCallId: 'dup', toolName: 'search', status: 'success' },
|
||||
{ toolCallId: 'dup', toolName: 'search', status: 'running' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const items = [...(container?.querySelectorAll('li') ?? [])];
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]?.textContent).toContain('success');
|
||||
expect(items[1]?.textContent).toContain('running');
|
||||
|
||||
const duplicateKeyWarning = consoleError.mock.calls.some((args) =>
|
||||
args.some((arg) => typeof arg === 'string' && arg.includes('same key')),
|
||||
);
|
||||
expect(duplicateKeyWarning).toBe(false);
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import type { ToolCallState } from './use-chat-connection';
|
||||
|
||||
export function ToolCallList({ tools }: { tools: ToolCallState[] }): ReactElement | null {
|
||||
if (tools.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul aria-label="Tool calls" className="flex flex-col gap-1 px-4 pb-2 text-xs">
|
||||
{tools.map((tool, index) => (
|
||||
<li
|
||||
// A valid server-controlled toolCallId can legitimately repeat
|
||||
// (e.g. two tool:start events sharing one id) — keying on it alone
|
||||
// would give React two identical keys. Pairing it with its
|
||||
// (stable, append-only) render index keeps every key unique.
|
||||
key={`${tool.toolCallId}-${index}`}
|
||||
role={tool.status === 'error' || tool.status === 'anomaly' ? 'alert' : 'status'}
|
||||
>
|
||||
{tool.toolName} —{' '}
|
||||
{tool.status === 'anomaly' ? 'unexpected end (unknown tool call)' : tool.status}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,135 +0,0 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { useSessionMock } = vi.hoisted(() => ({
|
||||
useSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { AuthGuard, GuestGuard } from './guards';
|
||||
|
||||
interface RenderedRouter {
|
||||
container: HTMLDivElement;
|
||||
router: ReturnType<typeof createMemoryRouter>;
|
||||
}
|
||||
|
||||
const mountedRoots: Root[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
async function renderRouter(
|
||||
routeObjects: RouteObject[],
|
||||
initialEntry: string,
|
||||
): Promise<RenderedRouter> {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const router = createMemoryRouter(routeObjects, { initialEntries: [initialEntry] });
|
||||
const root = createRoot(container);
|
||||
mountedRoots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
return { container, router };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
document.body.replaceChildren();
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
const guestRoutes: RouteObject[] = [
|
||||
{
|
||||
path: '/login',
|
||||
element: <GuestGuard />,
|
||||
children: [{ index: true, element: <p>Guest page</p> }],
|
||||
},
|
||||
{ path: '/chat', element: <p>Chat page</p> },
|
||||
];
|
||||
|
||||
const authenticatedRoutes: RouteObject[] = [
|
||||
{
|
||||
path: '/chat',
|
||||
element: <AuthGuard />,
|
||||
children: [{ index: true, element: <p>Private page</p> }],
|
||||
},
|
||||
{ path: '/login', element: <p>Login page</p> },
|
||||
];
|
||||
|
||||
describe('GuestGuard', () => {
|
||||
it('renders the guest outlet while session lookup is pending', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||
|
||||
const view = await renderRouter(guestRoutes, '/login');
|
||||
|
||||
expect(view.container.textContent).toContain('Guest page');
|
||||
expect(view.router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('renders the guest outlet when no session exists', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||
|
||||
const view = await renderRouter(guestRoutes, '/login');
|
||||
|
||||
expect(view.container.textContent).toContain('Guest page');
|
||||
expect(view.router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('redirects an authenticated session to chat', async () => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const view = await renderRouter(guestRoutes, '/login');
|
||||
|
||||
expect(view.container.textContent).toContain('Chat page');
|
||||
expect(view.router.state.location.pathname).toBe('/chat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
it('renders the existing loading treatment while session lookup is pending', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||
|
||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||
|
||||
expect(view.container.textContent).toContain('Loading...');
|
||||
expect(view.router.state.location.pathname).toBe('/chat');
|
||||
});
|
||||
|
||||
it('redirects an unauthenticated visitor to login', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||
|
||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||
|
||||
expect(view.container.textContent).toContain('Login page');
|
||||
expect(view.router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('renders the authenticated outlet when a session exists', async () => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||
|
||||
expect(view.container.textContent).toContain('Private page');
|
||||
expect(view.router.state.location.pathname).toBe('/chat');
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
export function GuestGuard(): ReactElement {
|
||||
const { data: session } = useSession();
|
||||
|
||||
return session ? <Navigate to="/chat" replace /> : <Outlet />;
|
||||
}
|
||||
|
||||
export function AuthGuard(): ReactElement {
|
||||
const { data: session, isPending } = useSession();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { useSessionMock } = vi.hoisted(() => ({
|
||||
useSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { routes } from '@/routes';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
function Boom(): never {
|
||||
throw new Error('render blew up');
|
||||
}
|
||||
|
||||
/** Recursively clones the real exported route table, replacing only the
|
||||
* `/chat` route's `element` with `<Boom />` — every other route (including
|
||||
* the real `AuthGuard` nesting and the real `/chat` `errorElement`) is left
|
||||
* exactly as exported. This is what makes the test fail if a future change
|
||||
* removes the real route's `errorElement`, unlike a hand-built independent
|
||||
* route tree that could drift from production undetected. */
|
||||
function replaceChatElementWithBoom(nodes: RouteObject[]): RouteObject[] {
|
||||
return nodes.map((node) => {
|
||||
const cloned: RouteObject = { ...node };
|
||||
if (cloned.path === '/chat') {
|
||||
cloned.element = <Boom />;
|
||||
}
|
||||
if (cloned.children) {
|
||||
cloned.children = replaceChatElementWithBoom(cloned.children);
|
||||
}
|
||||
return cloned;
|
||||
});
|
||||
}
|
||||
|
||||
let root: Root | null;
|
||||
let container: HTMLElement;
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
describe('ChatRouteErrorBoundary', () => {
|
||||
it('renders a recoverable, non-blank fallback when the /chat route element throws during render', async () => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const routeObjects = replaceChatElementWithBoom(routes);
|
||||
const router = createMemoryRouter(routeObjects, { initialEntries: ['/chat'] });
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(container.textContent).not.toBe('');
|
||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* `/chat` renders live, server-driven state (streamed text, tool calls,
|
||||
* manifests) that can carry malformed payloads no compile-time contract can
|
||||
* fully rule out at every dereference site. This is the last line of
|
||||
* defense: if something still throws during render, show a recoverable
|
||||
* alert instead of leaving the user on a blank/white screen.
|
||||
*/
|
||||
export function ChatRouteErrorBoundary(): ReactElement {
|
||||
useRouteError();
|
||||
|
||||
return (
|
||||
<div role="alert" className="flex min-h-screen flex-col items-center justify-center gap-3 p-8">
|
||||
<p className="text-sm font-medium">Something went wrong loading chat.</p>
|
||||
<a href="/chat" className="text-sm underline">
|
||||
Reload chat
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,636 +0,0 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createFakeChatSocket } from '@/spa/chat/test-support/fake-chat-socket';
|
||||
import { MAX_MANIFEST_ITEMS } from '@/spa/chat/limits';
|
||||
|
||||
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
|
||||
getSocketMock: vi.fn(),
|
||||
destroySocketMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/socket', () => ({
|
||||
getSocket: getSocketMock,
|
||||
destroySocket: destroySocketMock,
|
||||
}));
|
||||
|
||||
import { ChatPage } from './chat';
|
||||
|
||||
function setValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void {
|
||||
const proto =
|
||||
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
||||
setter?.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function selectValue(el: HTMLSelectElement, value: string): void {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
||||
setter?.call(el, value);
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(text),
|
||||
);
|
||||
if (!button) throw new Error(`Button with text "${text}" not found`);
|
||||
return button;
|
||||
}
|
||||
|
||||
let fake: ReturnType<typeof createFakeChatSocket>;
|
||||
let root: Root | null;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
fake = createFakeChatSocket();
|
||||
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
||||
destroySocketMock.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<ChatPage />);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe('ChatPage', () => {
|
||||
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||
});
|
||||
await act(async () => {
|
||||
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'pondering…' });
|
||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hel' });
|
||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'lo!' });
|
||||
fake.serverEmit('agent:tool:start', {
|
||||
conversationId: 'c1',
|
||||
toolCallId: 't1',
|
||||
toolName: 'web_search',
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('pondering…');
|
||||
expect(container.textContent).toContain('Hello!');
|
||||
expect(container.textContent).toContain('web_search');
|
||||
expect(container.textContent).toMatch(/running/i);
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('agent:tool:end', {
|
||||
conversationId: 'c1',
|
||||
toolCallId: 't1',
|
||||
toolName: 'web_search',
|
||||
isError: false,
|
||||
});
|
||||
fake.serverEmit('agent:end', {
|
||||
conversationId: 'c1',
|
||||
usage: {
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
thinkingLevel: 'medium',
|
||||
tokens: { input: 12, output: 34, cacheRead: 0, cacheWrite: 0, total: 46 },
|
||||
cost: 0.02,
|
||||
context: { percent: 3, window: 200000 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.textContent).toMatch(/success/i);
|
||||
expect(container.textContent).toContain('Hello!');
|
||||
expect(container.textContent).toMatch(/46/);
|
||||
});
|
||||
|
||||
it('renders the commands manifest and session info, and lets the user pick a thinking level', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('commands:manifest', {
|
||||
manifest: {
|
||||
commands: [
|
||||
{
|
||||
name: 'model',
|
||||
aliases: ['m'],
|
||||
description: 'Change the active model',
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
skills: [],
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
fake.serverEmit('session:info', {
|
||||
conversationId: 'c1',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
thinkingLevel: 'medium',
|
||||
availableThinkingLevels: ['low', 'medium', 'high'],
|
||||
routingDecision: {
|
||||
model: 'claude',
|
||||
provider: 'anthropic',
|
||||
ruleName: 'default',
|
||||
reason: 'default routing',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('model');
|
||||
expect(container.textContent).toContain('Change the active model');
|
||||
expect(container.textContent).toContain('anthropic');
|
||||
expect(container.textContent).toContain('default routing');
|
||||
|
||||
const select = container.querySelector(
|
||||
'select[aria-label="Thinking level"]',
|
||||
) as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
expect([...select.options].map((o) => o.value)).toEqual(['low', 'medium', 'high']);
|
||||
|
||||
await act(async () => {
|
||||
selectValue(select, 'high');
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'set:thinking',
|
||||
payload: { conversationId: 'c1', level: 'high' },
|
||||
});
|
||||
});
|
||||
|
||||
it('executes and approves commands with exact payloads and surfaces the approval affordance', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
|
||||
const commandInput = container.querySelector(
|
||||
'input[aria-label="Command name"]',
|
||||
) as HTMLInputElement;
|
||||
const argsInput = container.querySelector(
|
||||
'input[aria-label="Command arguments"]',
|
||||
) as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(commandInput, 'model');
|
||||
setValue(argsInput, 'gpt-5');
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Run command').click();
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'command:execute',
|
||||
payload: { conversationId: 'c1', command: 'model', args: 'gpt-5' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
setValue(commandInput, 'deploy');
|
||||
setValue(argsInput, 'prod');
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Request approval').click();
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'command:approve',
|
||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('command:approval', {
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: true,
|
||||
approvalId: 'ap1',
|
||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.textContent).toMatch(/approved/i);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Run approved command').click();
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'command:execute',
|
||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('shows visible alert surfaces for a server error and the structured contract reason for a failed command result', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('error', { conversationId: 'c1', error: 'The model is unavailable' });
|
||||
fake.serverEmit('command:result', {
|
||||
conversationId: 'c1',
|
||||
command: 'model',
|
||||
success: false,
|
||||
message: 'Unknown model',
|
||||
});
|
||||
});
|
||||
|
||||
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||
const alertText = alerts.map((node) => node.textContent).join(' ');
|
||||
expect(alertText).toContain('The model is unavailable');
|
||||
// The structured, contract-provided denial reason is visibly rendered.
|
||||
expect(alertText).toContain('Unknown model');
|
||||
});
|
||||
|
||||
it('falls back to a stable "Command failed." copy when a failed command result has no usable message', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmitRaw('command:result', {
|
||||
conversationId: 'c1',
|
||||
command: 'model',
|
||||
success: false,
|
||||
message: { bad: 'object' },
|
||||
});
|
||||
});
|
||||
|
||||
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||
const alertText = alerts.map((node) => node.textContent).join(' ');
|
||||
expect(alertText).toContain('Command failed.');
|
||||
});
|
||||
|
||||
it('caps availableThinkingLevels before storing and rendering a hostile session payload', async () => {
|
||||
const hostileLevels = Array.from({ length: MAX_MANIFEST_ITEMS + 50 }, (_, i) => `level-${i}`);
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('session:info', {
|
||||
conversationId: 'c1',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
thinkingLevel: 'level-0',
|
||||
availableThinkingLevels: hostileLevels,
|
||||
});
|
||||
});
|
||||
|
||||
const select = container.querySelector(
|
||||
'select[aria-label="Thinking level"]',
|
||||
) as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
expect(select.options.length).toBeLessThanOrEqual(MAX_MANIFEST_ITEMS);
|
||||
});
|
||||
|
||||
it('renders a safe fallback when session:info arrives with a malformed (non-array) availableThinkingLevels, without throwing', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmitRaw('session:info', {
|
||||
conversationId: 'c1',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
thinkingLevel: 'medium',
|
||||
availableThinkingLevels: null,
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||
const select = container.querySelector(
|
||||
'select[aria-label="Thinking level"]',
|
||||
) as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
// A malformed level list still shows a visible, safe placeholder option
|
||||
// rather than a silently empty select.
|
||||
expect([...select.options]).toHaveLength(1);
|
||||
expect(select.options[0]?.textContent).toMatch(/unavailable/i);
|
||||
|
||||
await act(async () => {
|
||||
selectValue(select, '');
|
||||
});
|
||||
expect(fake.emitted.filter((e) => e.event === 'set:thinking')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders honest unavailable labels — not fabricated zeros — when agent:end usage has malformed/missing numeric fields', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||
fake.serverEmitRaw('agent:end', {
|
||||
conversationId: 'c1',
|
||||
usage: {
|
||||
provider: { nested: 'object' },
|
||||
modelId: undefined,
|
||||
thinkingLevel: 'medium',
|
||||
tokens: { total: 'not-a-number' },
|
||||
cost: undefined,
|
||||
context: { percent: null, window: 200000 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const usage = container.querySelector('[aria-label="Usage"]');
|
||||
expect(usage).toBeTruthy();
|
||||
expect(usage?.textContent).toContain('tokens unavailable');
|
||||
expect(usage?.textContent).toContain('cost unavailable');
|
||||
expect(usage?.textContent).not.toContain('0 tokens');
|
||||
expect(usage?.textContent).not.toContain('$0.0000');
|
||||
expect(usage?.textContent).toContain('unknown/unknown');
|
||||
});
|
||||
|
||||
it('renders a safe fallback for message:ack when messageId is a malformed non-string value, without throwing', async () => {
|
||||
await expect(
|
||||
act(async () => {
|
||||
fake.serverEmitRaw('message:ack', { conversationId: 'c1', messageId: { bad: 'object' } });
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
const status = [...container.querySelectorAll('[role="status"]')].find((node) =>
|
||||
node.textContent?.includes('Message accepted'),
|
||||
);
|
||||
expect(status).toBeTruthy();
|
||||
// A malformed messageId gets a stable, visible fallback — never blank,
|
||||
// never the raw object.
|
||||
expect(status?.textContent).toContain('unknown');
|
||||
});
|
||||
|
||||
it('renders safely and does not throw when system:reload.message is a malformed non-string value', async () => {
|
||||
await expect(
|
||||
act(async () => {
|
||||
fake.serverEmitRaw('system:reload', {
|
||||
commands: [],
|
||||
skills: [],
|
||||
providers: [],
|
||||
message: { bad: 'object' },
|
||||
});
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
const status = container.querySelector('[role="status"]');
|
||||
expect(status).toBeTruthy();
|
||||
// A malformed reload message renders a stable, visible fallback rather
|
||||
// than a silently empty status line.
|
||||
expect(status?.textContent).toContain('Commands reloaded.');
|
||||
});
|
||||
|
||||
it('renders safely and does not throw when a scoped error carries a malformed non-string error value', async () => {
|
||||
await expect(
|
||||
act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmitRaw('error', { conversationId: 'c1', error: ['not', 'a', 'string'] });
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
const providerInput = container.querySelector(
|
||||
'input[aria-label="Provider"]',
|
||||
) as HTMLInputElement;
|
||||
const modelInput = container.querySelector('input[aria-label="Model"]') as HTMLInputElement;
|
||||
|
||||
const stopButtonBefore = container.querySelector(
|
||||
'button[aria-label="Stop"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(stopButtonBefore.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'hello there');
|
||||
setValue(providerInput, 'anthropic');
|
||||
setValue(modelInput, 'claude');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'message',
|
||||
payload: {
|
||||
conversationId: undefined,
|
||||
content: 'hello there',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
},
|
||||
});
|
||||
expect(container.textContent).toContain('hello there');
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||
});
|
||||
|
||||
const stopButtonDuring = container.querySelector(
|
||||
'button[aria-label="Stop"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(stopButtonDuring.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
stopButtonDuring.click();
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
||||
});
|
||||
|
||||
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'hello');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fake.serverEmit('session:info', {
|
||||
conversationId: 'c1',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
thinkingLevel: 'medium',
|
||||
availableThinkingLevels: ['low', 'medium', 'high'],
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain('anthropic');
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
|
||||
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain('anthropic');
|
||||
});
|
||||
|
||||
it('surfaces a pre-ack error as an alert without leaving the Stop control stuck active', async () => {
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'hello');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fake.serverEmit('error', {
|
||||
conversationId: 'c1',
|
||||
error: 'Failed to start agent session. Please try again.',
|
||||
});
|
||||
});
|
||||
|
||||
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||
expect(alerts.some((node) => node.textContent?.includes('Failed to start agent session'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const stopButton = container.querySelector('button[aria-label="Stop"]') as HTMLButtonElement;
|
||||
expect(stopButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows an accessible status once the message is acknowledged', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
|
||||
const statuses = [...container.querySelectorAll('[role="status"]')];
|
||||
expect(statuses.some((node) => node.textContent?.includes('m1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('renders finalized thinking text in the transcript after agent:end, not only while streaming', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'reasoning about it' });
|
||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Done.' });
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('reasoning about it');
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('agent:end', { conversationId: 'c1' });
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('reasoning about it');
|
||||
expect(container.textContent).toContain('Done.');
|
||||
});
|
||||
|
||||
it('ignores a concurrent approval request and only executes the approved command once', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
|
||||
const commandInput = container.querySelector(
|
||||
'input[aria-label="Command name"]',
|
||||
) as HTMLInputElement;
|
||||
const argsInput = container.querySelector(
|
||||
'input[aria-label="Command arguments"]',
|
||||
) as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(commandInput, 'deploy');
|
||||
setValue(argsInput, 'prod');
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Request approval').click();
|
||||
});
|
||||
await act(async () => {
|
||||
setValue(argsInput, 'staging');
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Request approval').click();
|
||||
});
|
||||
|
||||
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'command:approve',
|
||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('command:approval', {
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: true,
|
||||
approvalId: 'ap1',
|
||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Run approved command').click();
|
||||
findButton(container, 'Run approved command').click();
|
||||
});
|
||||
|
||||
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1);
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'command:execute',
|
||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('disables sending a second message while a turn is streaming', async () => {
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'first');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||
});
|
||||
|
||||
const sendButton = findButton(container, 'Send');
|
||||
expect(sendButton.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'second');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
|
||||
expect(fake.listeners.size).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
|
||||
for (const [, handlers] of fake.listeners) {
|
||||
expect(handlers.size).toBe(0);
|
||||
}
|
||||
expect(destroySocketMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { CommandsPanel } from '@/spa/chat/commands-panel';
|
||||
import { Composer } from '@/spa/chat/composer';
|
||||
import { MessageTranscript } from '@/spa/chat/message-transcript';
|
||||
import { asFiniteNumberOrNull, asString } from '@/spa/chat/runtime-guards';
|
||||
import { SessionPanel } from '@/spa/chat/session-panel';
|
||||
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
||||
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
||||
|
||||
/** Renders a real value normally, but an honest "unavailable" label instead
|
||||
* of a fabricated `0` for a missing/malformed count — a real `0 tokens` and
|
||||
* an unknown token count must never look the same. */
|
||||
function formatTokens(value: unknown): string {
|
||||
const tokens = asFiniteNumberOrNull(value);
|
||||
return tokens === null ? 'tokens unavailable' : `${tokens} tokens`;
|
||||
}
|
||||
|
||||
/** Same honesty guarantee as `formatTokens`, for cost. */
|
||||
function formatCost(value: unknown): string {
|
||||
const cost = asFiniteNumberOrNull(value);
|
||||
return cost === null ? 'cost unavailable' : `$${cost.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export function ChatPage(): ReactElement {
|
||||
const { state, actions } = useChatConnection();
|
||||
const hasConversation = state.conversationId !== null;
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] min-h-0 flex-col overflow-hidden md:h-screen">
|
||||
<header className="border-b px-4 py-3">
|
||||
<h1 className="text-lg font-semibold">Chat</h1>
|
||||
</header>
|
||||
|
||||
{state.systemReload ? (
|
||||
<div role="status" className="border-b px-4 py-2 text-sm">
|
||||
{asString(state.systemReload.message)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.error ? (
|
||||
<div role="alert" className="border-b px-4 py-2 text-sm">
|
||||
{asString(state.error)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.ack ? (
|
||||
<div role="status" className="border-b px-4 py-1 text-xs opacity-70">
|
||||
Message accepted · conversation {asString(state.ack.conversationId, 'unknown')} · id{' '}
|
||||
{asString(state.ack.messageId, 'unknown')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SessionPanel sessionInfo={state.sessionInfo} onSetThinking={actions.setThinking} />
|
||||
|
||||
<MessageTranscript messages={state.messages} streaming={state.streaming} text={state.text} />
|
||||
|
||||
{state.thinking ? (
|
||||
<section aria-label="Thinking" className="px-4 pb-2 text-xs italic opacity-80">
|
||||
{state.thinking}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<ToolCallList tools={state.tools} />
|
||||
|
||||
{state.usage ? (
|
||||
<div aria-label="Usage" className="px-4 pb-2 text-xs opacity-80">
|
||||
{formatTokens(state.usage.tokens?.total)} · {formatCost(state.usage.cost)} ·{' '}
|
||||
{asString(state.usage.provider, 'unknown')}/{asString(state.usage.modelId, 'unknown')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CommandsPanel
|
||||
manifest={state.manifest}
|
||||
results={state.commandResults}
|
||||
approval={state.approval}
|
||||
pendingApproval={state.pendingApproval}
|
||||
hasConversation={hasConversation}
|
||||
onExecute={actions.executeCommand}
|
||||
onApprove={actions.approveCommand}
|
||||
onRunApproved={actions.runApprovedCommand}
|
||||
/>
|
||||
|
||||
<Composer
|
||||
onSend={actions.sendMessage}
|
||||
onStop={actions.abort}
|
||||
streaming={state.streaming}
|
||||
sending={state.sending}
|
||||
hasConversation={hasConversation}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { useEffect, useState, type FormEvent, type ReactElement } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
|
||||
import { api } from '@/lib/api';
|
||||
import { authClient, signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
|
||||
export function LoginPage(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
||||
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
|
||||
SsoProviderDiscovery['id'] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
void api<SsoProviderDiscovery[]>('/api/sso/providers').then(
|
||||
(providers) => {
|
||||
if (active) setSsoProviders(providers.filter((provider) => provider.configured));
|
||||
},
|
||||
() => {
|
||||
if (active) setSsoProviders([]);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
const email = String(form.get('email') ?? '');
|
||||
const password = String(form.get('password') ?? '');
|
||||
|
||||
try {
|
||||
const result = await signIn.email({ email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Sign in failed');
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/chat', { replace: true });
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : 'Sign in failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
|
||||
setError(null);
|
||||
setSsoLoadingProviderId(providerId);
|
||||
|
||||
try {
|
||||
const result = await authClient.signIn.oauth2({
|
||||
providerId,
|
||||
callbackURL: '/chat',
|
||||
newUserCallbackURL: '/chat',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? `Sign in with ${providerId} failed`);
|
||||
setSsoLoadingProviderId(null);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : `Sign in with ${providerId} failed`);
|
||||
setSsoLoadingProviderId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<SsoProviderButtons
|
||||
providers={ssoProviders}
|
||||
loadingProviderId={ssoLoadingProviderId}
|
||||
onOidcSignIn={(providerId) => {
|
||||
void handleSsoSignIn(providerId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-text-muted">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-blue-400 hover:text-blue-300">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { Mission, Project, Task } from '@/lib/types';
|
||||
|
||||
export const projectFixtures: Project[] = [
|
||||
{
|
||||
id: 'project-1',
|
||||
name: 'Mosaic Stack',
|
||||
description: 'Gateway and dashboard parity work',
|
||||
status: 'active',
|
||||
userId: 'user-1',
|
||||
metadata: {
|
||||
prd: '# Mosaic Stack PRD\n\n## Objective\n\nShip the SPA route parity pages.',
|
||||
},
|
||||
createdAt: '2026-08-01T12:00:00.000Z',
|
||||
updatedAt: '2026-08-09T18:30:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'project-2',
|
||||
name: 'Agent Runtime',
|
||||
description: 'Pi SDK integration',
|
||||
status: 'paused',
|
||||
userId: 'user-1',
|
||||
metadata: null,
|
||||
createdAt: '2026-08-02T08:00:00.000Z',
|
||||
updatedAt: '2026-08-05T10:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const missionFixtures: Mission[] = [
|
||||
{
|
||||
id: 'mission-1',
|
||||
name: 'Ship web parity',
|
||||
description: 'Port read-only routes into the SPA',
|
||||
status: 'active',
|
||||
projectId: 'project-1',
|
||||
metadata: null,
|
||||
createdAt: '2026-08-03T12:00:00.000Z',
|
||||
updatedAt: '2026-08-09T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'mission-2',
|
||||
name: 'Unrelated mission',
|
||||
description: 'Must be filtered out of the project detail view',
|
||||
status: 'planning',
|
||||
projectId: 'project-2',
|
||||
metadata: null,
|
||||
createdAt: '2026-08-04T12:00:00.000Z',
|
||||
updatedAt: '2026-08-04T12:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const taskFixtures: Task[] = [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: 'Route /projects',
|
||||
description: 'Port the read-only projects listing into the SPA',
|
||||
status: 'done',
|
||||
priority: 'high',
|
||||
projectId: 'project-1',
|
||||
missionId: 'mission-1',
|
||||
assignee: 'Jarvis',
|
||||
tags: ['spa', 'projects'],
|
||||
dueDate: '2026-08-12T00:00:00.000Z',
|
||||
metadata: {
|
||||
notes: 'Read-only modal content should remain intact.',
|
||||
pr_links: [{ url: 'https://example.invalid/pr/1', label: 'PR #1' }],
|
||||
},
|
||||
createdAt: '2026-08-04T10:00:00.000Z',
|
||||
updatedAt: '2026-08-09T15:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
title: 'Route /projects/:id',
|
||||
description: 'Reuse overview, tasks, missions, and PRD tabs',
|
||||
status: 'in-progress',
|
||||
priority: 'critical',
|
||||
projectId: 'project-1',
|
||||
missionId: 'mission-1',
|
||||
assignee: null,
|
||||
tags: ['spa', 'detail'],
|
||||
dueDate: null,
|
||||
metadata: null,
|
||||
createdAt: '2026-08-05T09:00:00.000Z',
|
||||
updatedAt: '2026-08-10T08:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'task-3',
|
||||
title: 'Route /tasks',
|
||||
description: 'Wire list and kanban modal interactions',
|
||||
status: 'blocked',
|
||||
priority: 'medium',
|
||||
projectId: 'project-1',
|
||||
missionId: null,
|
||||
assignee: null,
|
||||
tags: ['spa', 'tasks'],
|
||||
dueDate: null,
|
||||
metadata: null,
|
||||
createdAt: '2026-08-06T09:00:00.000Z',
|
||||
updatedAt: '2026-08-08T08:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'task-4',
|
||||
title: 'Other project task',
|
||||
description: 'Used only to confirm mission filtering remains project-scoped',
|
||||
status: 'not-started',
|
||||
priority: 'low',
|
||||
projectId: 'project-2',
|
||||
missionId: 'mission-2',
|
||||
assignee: null,
|
||||
tags: null,
|
||||
dueDate: null,
|
||||
metadata: null,
|
||||
createdAt: '2026-08-06T09:00:00.000Z',
|
||||
updatedAt: '2026-08-06T09:00:00.000Z',
|
||||
},
|
||||
];
|
||||
@@ -1,174 +0,0 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { missionFixtures, projectFixtures, taskFixtures } from './page-fixtures';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
import { ProjectDetailPage } from './project-detail';
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
});
|
||||
|
||||
async function renderProjectDetailPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
||||
const routes: RouteObject[] = [
|
||||
{ path: '/projects', element: <p>Projects index target</p> },
|
||||
{ path: '/projects/:id', element: <ProjectDetailPage /> },
|
||||
];
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/projects/project-1'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function clickButtonByText(text: string): void {
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(text),
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`Button containing "${text}" not found`);
|
||||
}
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
}
|
||||
|
||||
describe('ProjectDetailPage', () => {
|
||||
it('loads the project, tasks, missions, and optional PRD content for the active project', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
expect(apiMock.mock.calls).toEqual([
|
||||
['/api/projects/project-1'],
|
||||
['/api/missions'],
|
||||
['/api/tasks?projectId=project-1'],
|
||||
]);
|
||||
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.textContent).toContain('Route /projects/:id');
|
||||
expect(container.textContent).toContain('Tasks');
|
||||
expect(container.textContent).toContain('Done');
|
||||
expect(container.textContent).toContain('Blocked');
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Missions (1)');
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Ship web parity');
|
||||
expect(container.textContent).not.toContain('Unrelated mission');
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('PRD');
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Mosaic Stack PRD');
|
||||
expect(container.textContent).toContain('Ship the SPA route parity pages.');
|
||||
});
|
||||
|
||||
it('opens and closes the existing read-only task modal from the tasks tab', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Tasks (3)');
|
||||
});
|
||||
|
||||
const row = [...container.querySelectorAll('tr')].find((candidate) =>
|
||||
candidate.textContent?.includes('Route /projects'),
|
||||
);
|
||||
expect(row).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain('Read-only modal content should remain intact.');
|
||||
|
||||
const closeButton = container.querySelector('button[aria-label="Close task details"]');
|
||||
expect(closeButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
closeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the project with an empty missions tab when the missions request fails', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockRejectedValueOnce(new Error('Missions request failed'))
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Missions (0)');
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('No missions for this project');
|
||||
});
|
||||
|
||||
it('renders a visible alert when the project request fails and lets the user navigate back', async () => {
|
||||
apiMock
|
||||
.mockRejectedValueOnce(new Error('Project request failed'))
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
|
||||
const router = await renderProjectDetailPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert?.textContent).toContain('Project request failed');
|
||||
expect(container.textContent).not.toContain('Mosaic Stack');
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Back to projects');
|
||||
});
|
||||
|
||||
expect(router.state.location.pathname).toBe('/projects');
|
||||
});
|
||||
});
|
||||
@@ -1,344 +0,0 @@
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { MissionTimeline } from '@/components/projects/mission-timeline';
|
||||
import { PrdViewer } from '@/components/projects/prd-viewer';
|
||||
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
|
||||
import { getErrorMessage } from './page-errors';
|
||||
|
||||
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
|
||||
|
||||
const projectStatusColors: Record<string, string> = {
|
||||
active: 'bg-success/20 text-success',
|
||||
paused: 'bg-warning/20 text-warning',
|
||||
completed: 'bg-blue-600/20 text-blue-400',
|
||||
archived: 'bg-gray-600/20 text-gray-400',
|
||||
};
|
||||
|
||||
const taskStatusColors: Record<string, string> = {
|
||||
'not-started': 'bg-gray-600/20 text-gray-300',
|
||||
'in-progress': 'bg-blue-600/20 text-blue-400',
|
||||
blocked: 'bg-error/20 text-error',
|
||||
done: 'bg-success/20 text-success',
|
||||
cancelled: 'bg-gray-600/20 text-gray-500',
|
||||
};
|
||||
|
||||
interface TabButtonProps {
|
||||
id: Tab;
|
||||
label: string;
|
||||
activeTab: Tab;
|
||||
onClick: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
function TabButton({ id, label, activeTab, onClick }: TabButtonProps): ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(id)}
|
||||
className={cn(
|
||||
'border-b-2 px-4 py-2 text-sm transition-colors',
|
||||
activeTab === id
|
||||
? 'border-text-primary text-text-primary'
|
||||
: 'border-transparent text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectDetailPage(): ReactElement {
|
||||
const { id = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [missions, setMissions] = useState<Mission[]>([]);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
setError('Project id is missing.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
void Promise.all([
|
||||
api<Project>('/api/projects/' + id),
|
||||
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
|
||||
api<Task[]>('/api/tasks?projectId=' + id).catch(() => [] as Task[]),
|
||||
])
|
||||
.then(([loadedProject, allMissions, loadedTasks]) => {
|
||||
if (cancelled) return;
|
||||
setProject(loadedProject);
|
||||
setMissions(allMissions.filter((mission) => mission.projectId === id));
|
||||
setTasks(loadedTasks);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(getErrorMessage(caught, 'Failed to load project.'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Project</h1>
|
||||
</header>
|
||||
<p className="py-16 text-center text-sm text-text-muted">Loading project...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !project) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Project</h1>
|
||||
</header>
|
||||
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||
{error ?? 'Project not found.'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="mt-4 w-fit text-sm underline"
|
||||
>
|
||||
Back to projects
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredTasks =
|
||||
taskFilter === 'all' ? tasks : tasks.filter((task) => task.status === taskFilter);
|
||||
const prdContent = getPrdContent(project);
|
||||
const tabs: Array<{ id: Tab; label: string }> = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'tasks', label: `Tasks (${tasks.length})` },
|
||||
{ id: 'missions', label: `Missions (${missions.length})` },
|
||||
...(prdContent ? [{ id: 'prd' as const, label: 'PRD' }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="hover:text-text-secondary"
|
||||
>
|
||||
Projects
|
||||
</button>
|
||||
<span>/</span>
|
||||
<span className="text-text-primary">{project.name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-xs',
|
||||
projectStatusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||
)}
|
||||
>
|
||||
{project.status}
|
||||
</span>
|
||||
</div>
|
||||
{project.description ? (
|
||||
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-xs text-text-muted">
|
||||
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
|
||||
{new Date(project.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard label="Tasks" value={String(tasks.length)} />
|
||||
<StatCard
|
||||
label="Done"
|
||||
value={String(tasks.filter((task) => task.status === 'done').length)}
|
||||
valueClass="text-success"
|
||||
/>
|
||||
<StatCard
|
||||
label="In Progress"
|
||||
value={String(tasks.filter((task) => task.status === 'in-progress').length)}
|
||||
valueClass="text-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Blocked"
|
||||
value={String(tasks.filter((task) => task.status === 'blocked').length)}
|
||||
valueClass={tasks.some((task) => task.status === 'blocked') ? 'text-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex gap-0 border-b border-surface-border">
|
||||
{tabs.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
id={tab.id}
|
||||
label={tab.label}
|
||||
activeTab={activeTab}
|
||||
onClick={setActiveTab}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' ? (
|
||||
<OverviewTab project={project} missions={missions} tasks={tasks} />
|
||||
) : null}
|
||||
|
||||
{activeTab === 'tasks' ? (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<TaskStatusSummary
|
||||
tasks={tasks}
|
||||
activeFilter={taskFilter}
|
||||
onFilterChange={setTaskFilter}
|
||||
/>
|
||||
</div>
|
||||
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'missions' ? <MissionTimeline missions={missions} /> : null}
|
||||
|
||||
{activeTab === 'prd' && prdContent ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
|
||||
<PrdViewer content={prdContent} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedTask ? (
|
||||
<TaskDetailModal task={selectedTask} onClose={() => setSelectedTask(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
project,
|
||||
missions,
|
||||
tasks,
|
||||
}: {
|
||||
project: Project;
|
||||
missions: Mission[];
|
||||
tasks: Task[];
|
||||
}): ReactElement {
|
||||
const recentTasks = [...tasks]
|
||||
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
|
||||
{recentTasks.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||
<p className="text-sm text-text-muted">No tasks yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-card px-3 py-2"
|
||||
>
|
||||
<span className="truncate text-sm text-text-primary">{task.title}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-2 py-0.5 text-xs',
|
||||
taskStatusColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||
)}
|
||||
>
|
||||
{task.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
|
||||
{missions.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||
<p className="text-sm text-text-muted">No missions yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<MissionTimeline missions={missions.slice(0, 4)} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{project.metadata && Object.keys(project.metadata).length > 0 ? (
|
||||
<section className="lg:col-span-2">
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Project Metadata</h2>
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<pre className="overflow-x-auto text-xs text-text-muted">
|
||||
{JSON.stringify(project.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
valueClass,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
valueClass?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-3">
|
||||
<p className="text-xs text-text-muted">{label}</p>
|
||||
<p className={cn('mt-1 text-lg font-semibold', valueClass ?? 'text-text-primary')}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPrdContent(project: Project): string | null {
|
||||
if (!project.metadata) return null;
|
||||
|
||||
const prd = project.metadata['prd'];
|
||||
if (typeof prd === 'string' && prd.trim().length > 0) {
|
||||
return prd;
|
||||
}
|
||||
|
||||
const prdContent = project.metadata['prdContent'];
|
||||
if (typeof prdContent === 'string' && prdContent.trim().length > 0) {
|
||||
return prdContent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { projectFixtures } from './page-fixtures';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
import { ProjectsPage } from './projects';
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
});
|
||||
|
||||
async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
||||
const routes: RouteObject[] = [
|
||||
{ path: '/projects', element: <ProjectsPage /> },
|
||||
{ path: '/projects/:id', element: <p>Project detail target</p> },
|
||||
];
|
||||
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/projects'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
describe('ProjectsPage', () => {
|
||||
it('shows a visible loading state while the project request is in flight', async () => {
|
||||
const deferred = createDeferred<typeof projectFixtures>();
|
||||
apiMock.mockReturnValueOnce(deferred.promise);
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
expect(container.textContent).toContain('Loading projects...');
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(projectFixtures);
|
||||
await deferred.promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('renders project cards from the API and navigates to a project detail route on click', async () => {
|
||||
apiMock.mockResolvedValueOnce(projectFixtures);
|
||||
|
||||
const router = await renderProjectsPage();
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/projects');
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.textContent).toContain('Agent Runtime');
|
||||
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes('Mosaic Stack'),
|
||||
);
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(router.state.location.pathname).toBe('/projects/project-1');
|
||||
expect(container.textContent).toContain('Project detail target');
|
||||
});
|
||||
|
||||
it('renders the empty state when the API returns no projects', async () => {
|
||||
apiMock.mockResolvedValueOnce([]);
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
expect(container.textContent).toContain('No projects yet');
|
||||
expect(container.textContent).toContain(
|
||||
'Projects will appear here when created via the gateway API',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a visible alert when the projects request fails', async () => {
|
||||
apiMock.mockRejectedValueOnce(new Error('Projects are unavailable'));
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert?.textContent).toContain('Projects are unavailable');
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ProjectCard } from '@/components/projects/project-card';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Project } from '@/lib/types';
|
||||
import { getErrorMessage } from './page-errors';
|
||||
|
||||
export function ProjectsPage(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void api<Project[]>('/api/projects')
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setProjects(response);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(getErrorMessage(caught, 'Failed to load projects.'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Projects</h1>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
Projects will appear here when created via the gateway API
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
onClick={(selectedProject) => navigate(`/projects/${selectedProject.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useState, type FormEvent, type ReactElement } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { signUp } from '@/lib/auth-client';
|
||||
|
||||
export function RegisterPage(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
const name = String(form.get('name') ?? '');
|
||||
const email = String(form.get('email') ?? '');
|
||||
const password = String(form.get('password') ?? '');
|
||||
|
||||
try {
|
||||
const result = await signUp.email({ name, email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Registration failed');
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/chat', { replace: true });
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Create account</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-text-muted">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-blue-400 hover:text-blue-300">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { useSessionMock } = vi.hoisted(() => ({
|
||||
useSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { routes } from '@/routes';
|
||||
|
||||
function Boom(): never {
|
||||
throw new Error('resource route render blew up');
|
||||
}
|
||||
|
||||
function replaceRouteElementWithBoom(nodes: RouteObject[], path: string): RouteObject[] {
|
||||
return nodes.map((node) => {
|
||||
const cloned: RouteObject = { ...node };
|
||||
if (cloned.path === path) {
|
||||
cloned.element = <Boom />;
|
||||
}
|
||||
if (cloned.children) {
|
||||
cloned.children = replaceRouteElementWithBoom(cloned.children, path);
|
||||
}
|
||||
return cloned;
|
||||
});
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
describe('resource route error boundaries', () => {
|
||||
it.each(['/projects', '/projects/:id', '/tasks'])(
|
||||
'renders a recoverable fallback when %s throws during route render',
|
||||
async (path) => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const initialEntry = path === '/projects/:id' ? '/projects/project-1' : path;
|
||||
const router = createMemoryRouter(replaceRouteElementWithBoom(routes, path), {
|
||||
initialEntries: [initialEntry],
|
||||
});
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(container.textContent).not.toBe('');
|
||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
|
||||
interface ResourceRouteErrorBoundaryProps {
|
||||
message: string;
|
||||
href: string;
|
||||
linkLabel: string;
|
||||
}
|
||||
|
||||
function ResourceRouteErrorBoundary({
|
||||
message,
|
||||
href,
|
||||
linkLabel,
|
||||
}: ResourceRouteErrorBoundaryProps): ReactElement {
|
||||
useRouteError();
|
||||
|
||||
return (
|
||||
<div role="alert" className="flex min-h-screen flex-col items-center justify-center gap-3 p-8">
|
||||
<p className="text-sm font-medium">{message}</p>
|
||||
<a href={href} className="text-sm underline">
|
||||
{linkLabel}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsRouteErrorBoundary(): ReactElement {
|
||||
return (
|
||||
<ResourceRouteErrorBoundary
|
||||
message="Something went wrong loading projects."
|
||||
href="/projects"
|
||||
linkLabel="Reload projects"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectDetailRouteErrorBoundary(): ReactElement {
|
||||
return (
|
||||
<ResourceRouteErrorBoundary
|
||||
message="Something went wrong loading this project."
|
||||
href="/projects"
|
||||
linkLabel="Back to projects"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TasksRouteErrorBoundary(): ReactElement {
|
||||
return (
|
||||
<ResourceRouteErrorBoundary
|
||||
message="Something went wrong loading tasks."
|
||||
href="/tasks"
|
||||
linkLabel="Reload tasks"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { apiMock, oauth2Mock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
oauth2Mock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
signIn: { oauth2: oauth2Mock },
|
||||
}));
|
||||
|
||||
import { SsoCallbackPage } from './sso-callback';
|
||||
|
||||
const mountedRoots: Root[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
apiMock.mockResolvedValue([
|
||||
{
|
||||
id: 'authentik',
|
||||
name: 'Authentik',
|
||||
protocols: ['oidc'],
|
||||
configured: true,
|
||||
loginMode: 'oidc',
|
||||
callbackPath: '/api/auth/oauth2/callback/authentik',
|
||||
teamSync: { enabled: false, claim: null },
|
||||
samlFallback: { configured: false, loginUrl: null },
|
||||
warnings: [],
|
||||
},
|
||||
]);
|
||||
oauth2Mock.mockResolvedValue({ data: null, error: null });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
document.body.replaceChildren();
|
||||
apiMock.mockReset();
|
||||
oauth2Mock.mockReset();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
describe('SsoCallbackPage', () => {
|
||||
it('rejects a control-character callback that normalizes to an external origin', async () => {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: '/auth/provider/:provider',
|
||||
element: <SsoCallbackPage />,
|
||||
},
|
||||
],
|
||||
{
|
||||
initialEntries: ['/auth/provider/authentik?callbackURL=%2F%0A%2F%2Fevil.example'],
|
||||
},
|
||||
);
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
mountedRoots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RouterProvider router={router} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(oauth2Mock).toHaveBeenCalledWith({
|
||||
providerId: 'authentik',
|
||||
callbackURL: '/chat',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
|
||||
export function SsoCallbackPage(): ReactElement {
|
||||
const { provider: providerId = '' } = useParams<'provider'>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function redirectToProvider(): Promise<void> {
|
||||
try {
|
||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
||||
if (cancelled) return;
|
||||
|
||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||
if (!provider) {
|
||||
setError('Unknown SSO provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderName(provider.name);
|
||||
if (!provider.configured) {
|
||||
setError(`${provider.name} is not enabled in this deployment.`);
|
||||
return;
|
||||
}
|
||||
if (provider.loginMode !== 'oidc') {
|
||||
setError(`${provider.name} is not available for OIDC sign in.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signIn.oauth2({
|
||||
providerId: provider.id,
|
||||
callbackURL,
|
||||
});
|
||||
|
||||
if (!cancelled && result?.error) {
|
||||
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void redirectToProvider();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [providerId, requestedCallbackURL]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{providerName
|
||||
? `Redirecting you to ${providerName}...`
|
||||
: 'Preparing your sign-in request...'}
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
<p>{error}</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Return to login
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
||||
If the redirect does not start automatically, return to the login page and try again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { taskFixtures } from './page-fixtures';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
import { TasksPage } from './tasks';
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
});
|
||||
|
||||
async function renderTasksPage(): Promise<void> {
|
||||
const routes: RouteObject[] = [{ path: '/tasks', element: <TasksPage /> }];
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/tasks'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
}
|
||||
|
||||
function clickButtonByText(text: string): void {
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(text),
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`Button containing "${text}" not found`);
|
||||
}
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
}
|
||||
|
||||
describe('TasksPage', () => {
|
||||
it('shows a visible loading state before the tasks request settles', async () => {
|
||||
const deferred = createDeferred<typeof taskFixtures>();
|
||||
apiMock.mockReturnValueOnce(deferred.promise);
|
||||
|
||||
await renderTasksPage();
|
||||
|
||||
expect(container.textContent).toContain('Loading tasks...');
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(taskFixtures);
|
||||
await deferred.promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('starts in kanban view, toggles to list view, and opens the read-only modal from cards and rows', async () => {
|
||||
apiMock.mockResolvedValueOnce(taskFixtures);
|
||||
|
||||
await renderTasksPage();
|
||||
|
||||
expect(container.textContent).toContain('Not Started');
|
||||
expect(container.textContent).toContain('In Progress');
|
||||
expect(container.textContent).toContain('Blocked');
|
||||
|
||||
const kanbanCard = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes('Route /projects/:id'),
|
||||
);
|
||||
expect(kanbanCard).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
kanbanCard?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector('button[aria-label="Close task details"]')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('List');
|
||||
});
|
||||
|
||||
const row = [...container.querySelectorAll('tr')].find((candidate) =>
|
||||
candidate.textContent?.includes('Route /tasks'),
|
||||
);
|
||||
expect(row).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain('Wire list and kanban modal interactions');
|
||||
});
|
||||
|
||||
it('renders a visible alert when the tasks request fails', async () => {
|
||||
apiMock.mockRejectedValueOnce(new Error('Tasks request failed'));
|
||||
|
||||
await renderTasksPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert?.textContent).toContain('Tasks request failed');
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import { KanbanBoard } from '@/components/tasks/kanban-board';
|
||||
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
import { getErrorMessage } from './page-errors';
|
||||
|
||||
type ViewMode = 'list' | 'kanban';
|
||||
|
||||
export function TasksPage(): ReactElement {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [view, setView] = useState<ViewMode>('kanban');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void api<Task[]>('/api/tasks')
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setTasks(response);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(getErrorMessage(caught, 'Failed to load tasks.'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 flex items-center justify-between gap-4 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Tasks</h1>
|
||||
<div className="flex rounded-lg border border-surface-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('list')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs transition-colors',
|
||||
view === 'list'
|
||||
? 'bg-surface-elevated text-text-primary'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('kanban')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs transition-colors',
|
||||
view === 'kanban'
|
||||
? 'bg-surface-elevated text-text-primary'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
Kanban
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
|
||||
) : view === 'kanban' ? (
|
||||
<KanbanBoard tasks={tasks} onTaskClick={setSelectedTask} />
|
||||
) : (
|
||||
<TaskListView tasks={tasks} onTaskClick={setSelectedTask} />
|
||||
)}
|
||||
|
||||
{selectedTask ? (
|
||||
<TaskDetailModal task={selectedTask} onClose={() => setSelectedTask(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export function Placeholder({ title }: { title: string }): ReactElement {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<h1 className="text-xl font-medium">{title}</h1>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { isValidElement } from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RouteObject } from 'react-router-dom';
|
||||
import { routes } from '@/routes';
|
||||
import { Placeholder } from '@/spa/placeholder';
|
||||
import { ChatPage } from '@/spa/pages/chat';
|
||||
import { ProjectDetailPage } from '@/spa/pages/project-detail';
|
||||
import { ProjectsPage } from '@/spa/pages/projects';
|
||||
import { TasksPage } from '@/spa/pages/tasks';
|
||||
|
||||
function collectPaths(routeObjects: RouteObject[]): string[] {
|
||||
return routeObjects.flatMap((route) => [
|
||||
...(route.path ? [route.path] : []),
|
||||
...(route.children ? collectPaths(route.children) : []),
|
||||
]);
|
||||
}
|
||||
|
||||
function findRoute(routeObjects: RouteObject[], path: string): RouteObject | undefined {
|
||||
for (const route of routeObjects) {
|
||||
if (route.path === path) return route;
|
||||
const nested = route.children ? findRoute(route.children, path) : undefined;
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('SPA route table', () => {
|
||||
it('covers every v1 parity route from the Phase P RFC', () => {
|
||||
expect(collectPaths(routes).sort()).toEqual(
|
||||
[
|
||||
'/',
|
||||
'/admin',
|
||||
'/auth/provider/:provider',
|
||||
'/chat',
|
||||
'/login',
|
||||
'/projects',
|
||||
'/projects/:id',
|
||||
'/register',
|
||||
'/settings',
|
||||
'/tasks',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('separates guest and authenticated route groups', () => {
|
||||
const guestPaths = collectPaths(routes.at(0)?.children ?? []);
|
||||
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
||||
expect(guestPaths).toContain('/login');
|
||||
expect(guestPaths).not.toContain('/chat');
|
||||
expect(authPaths).toContain('/chat');
|
||||
});
|
||||
|
||||
it.each(['/login', '/register', '/auth/provider/:provider'])(
|
||||
'renders a real guest page instead of the P1 placeholder at %s',
|
||||
(path) => {
|
||||
const element = findRoute(routes, path)?.element;
|
||||
expect(isValidElement(element)).toBe(true);
|
||||
if (!isValidElement(element)) throw new Error(`Missing route element for ${path}`);
|
||||
expect(element.type).not.toBe(Placeholder);
|
||||
},
|
||||
);
|
||||
|
||||
it('renders the real chat page instead of the P1 placeholder at /chat, inside the authenticated group', () => {
|
||||
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
||||
expect(authPaths).toContain('/chat');
|
||||
|
||||
const element = findRoute(routes, '/chat')?.element;
|
||||
expect(isValidElement(element)).toBe(true);
|
||||
if (!isValidElement(element)) throw new Error('Missing route element for /chat');
|
||||
expect(element.type).not.toBe(Placeholder);
|
||||
expect(element.type).toBe(ChatPage);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/projects', ProjectsPage],
|
||||
['/projects/:id', ProjectDetailPage],
|
||||
['/tasks', TasksPage],
|
||||
])(
|
||||
'renders a real authenticated page instead of the P1 placeholder at %s',
|
||||
(path, expectedType) => {
|
||||
const element = findRoute(routes, path)?.element;
|
||||
expect(isValidElement(element)).toBe(true);
|
||||
if (!isValidElement(element)) throw new Error(`Missing route element for ${path}`);
|
||||
expect(element.type).not.toBe(Placeholder);
|
||||
expect(element.type).toBe(expectedType);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['/chat', '/projects', '/projects/:id', '/tasks'])(
|
||||
'defines an error boundary for %s',
|
||||
(path) => {
|
||||
const route = findRoute(routes, path);
|
||||
expect(route?.errorElement).toBeTruthy();
|
||||
expect(isValidElement(route?.errorElement)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Vitest abort-controller realm', () => {
|
||||
it('provides a global signal accepted by Node native Request', () => {
|
||||
const controller = new AbortController();
|
||||
const request = new Request('https://mosaic.invalid/navigation', {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(request.signal).toBeInstanceOf(AbortSignal);
|
||||
controller.abort();
|
||||
expect(request.signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { transferableAbortController } from 'node:util';
|
||||
|
||||
// jsdom installs realm-local abort constructors while Node's undici Request
|
||||
// remains native. React Router passes a global AbortSignal to Request, so both
|
||||
// constructors must come from Node's native realm during tests.
|
||||
const nativeController = transferableAbortController();
|
||||
const nativeAbortController = nativeController.constructor;
|
||||
const nativeAbortSignal = nativeController.signal.constructor;
|
||||
|
||||
for (const target of [globalThis, window]) {
|
||||
Object.defineProperties(target, {
|
||||
AbortController: {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: nativeAbortController,
|
||||
},
|
||||
AbortSignal: {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: nativeAbortSignal,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// The proxy exists only in dev; in production the SPA is same-origin with the gateway
|
||||
// (served by it under Candidate A, or behind one FQDN under Candidate B) and every
|
||||
// request uses a relative path, so no origin may ever be configured here or in src/.
|
||||
const gatewayTarget = 'http://localhost:14242';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3100,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': gatewayTarget,
|
||||
'/socket.io': { target: gatewayTarget, ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,21 +1,9 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
// tsconfig uses "jsx": "preserve" for Next; tests need esbuild to compile it
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
isolate: true,
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
},
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user