Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,92 @@ jobs:
retention-days: 14
if-no-files-found: ignore

researcher-e2e:
name: Researcher / Unit + Playwright E2E
runs-on: ubuntu-latest
timeout-minutes: 25

services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: memwal
POSTGRES_PASSWORD: memwal_secret
POSTGRES_DB: memwal
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U memwal"
--health-interval 5s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7-alpine
ports: ["6379:6379"]
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-retries 10

env:
POSTGRES_URL: postgresql://memwal:memwal_secret@localhost:5432/memwal
REDIS_URL: redis://localhost:6379
AUTH_SECRET: ci-test-secret-not-for-production
OPENROUTER_API_KEY: mock-not-used-tests-use-local-mocks
# Only read by the delegate-account binding mock to fabricate the
# MemWalAccount type; any non-empty 0x id works.
NEXT_PUBLIC_MEMWAL_PACKAGE_ID: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
PORT: "3000"
PLAYWRIGHT: "True"
NODE_ENV: test

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: pnpm

- name: Install deps
run: pnpm install --frozen-lockfile

- name: Test and build SDK (workspace dep of researcher)
run: pnpm --filter @mysten-incubation/memwal test

# Fast node:test unit tests (no DB/browser). Includes the retired-model
# regression tests for the title-model production incident.
- name: Unit tests
run: pnpm --filter researcher test:unit

- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: pw-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pw-${{ runner.os }}-

- name: Install Playwright (Chromium + OS deps)
run: pnpm --filter researcher playwright:install

- name: Run Playwright E2E
run: pnpm --filter researcher test:e2e

- name: Upload Playwright report + traces
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-researcher
path: |
apps/researcher/playwright-report
apps/researcher/test-results
retention-days: 14
if-no-files-found: ignore

server-e2e:
name: Server / E2E
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions apps/researcher/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ yarn-error.log*
/playwright-report/
/blob-report/
/playwright/*
/tests/playwright/.auth/
39 changes: 29 additions & 10 deletions apps/researcher/lib/ai/models.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,25 +121,44 @@ const createMockReasoningModel = (): LanguageModel => {
} as unknown as LanguageModel;
};

/**
* When a user message contains this sentinel, the mock title model rejects.
* The e2e suite uses it to reproduce the retired-title-model production
* failure and assert the chat stream still completes without an error part
* (the try/catch guard in app/(chat)/api/chat/route.ts).
* Mirrored in tests/playwright/helpers.ts — keep the two in sync.
*/
export const TITLE_FAILURE_SENTINEL = "FAIL_TITLE_GENERATION";

function assertTitlePromptOk(prompt: unknown): void {
if (JSON.stringify(prompt).includes(TITLE_FAILURE_SENTINEL)) {
throw new Error("Mock title model failure (TITLE_FAILURE_SENTINEL)");
}
}

const createMockTitleModel = (): LanguageModel => {
return {
specificationVersion: "v3",
provider: "mock",
modelId: "mock-title-model",
defaultObjectGenerationMode: "tool",
supportedUrls: {},
doGenerate: async () => ({
finishReason: "stop",
usage: {
inputTokens: { total: 5, noCache: 5, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 5, text: 5, reasoning: 0 },
},
content: [{ type: "text", text: "Test Conversation" }],
warnings: [],
}),
doStream: () => ({
doGenerate: async ({ prompt }: { prompt: unknown }) => {
assertTitlePromptOk(prompt);
return {
finishReason: "stop",
usage: {
inputTokens: { total: 5, noCache: 5, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 5, text: 5, reasoning: 0 },
},
content: [{ type: "text", text: "Test Conversation" }],
warnings: [],
};
},
doStream: ({ prompt }: { prompt: unknown }) => ({
stream: new ReadableStream({
start(controller) {
assertTitlePromptOk(prompt);
controller.enqueue({ type: "text-start", id: "t1" });
controller.enqueue({
type: "text-delta",
Expand Down
27 changes: 23 additions & 4 deletions apps/researcher/lib/ai/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,33 @@ export const myProvider = isTestEnvironment
})()
: null;

function isReasoningModelId(modelId: string) {
return (
modelId.endsWith("-thinking") ||
(modelId.includes("reasoning") && !modelId.includes("non-reasoning"))
);
}

const MOCK_MODEL_IDS = new Set([
"chat-model",
"chat-model-reasoning",
"title-model",
]);

export function getLanguageModel(modelId: string) {
if (isTestEnvironment && myProvider) {
return myProvider.languageModel(modelId);
// The picker sends real OpenRouter ids; the mock provider only registers
// its own three, so anything else must map onto them or languageModel()
// throws NoSuchModelError on the first test message.
if (MOCK_MODEL_IDS.has(modelId)) {
return myProvider.languageModel(modelId);
}
return myProvider.languageModel(
isReasoningModelId(modelId) ? "chat-model-reasoning" : "chat-model"
);
}

const isReasoningModel =
modelId.endsWith("-thinking") ||
(modelId.includes("reasoning") && !modelId.includes("non-reasoning"));
const isReasoningModel = isReasoningModelId(modelId);

if (isReasoningModel) {
const gatewayModelId = modelId.replace(THINKING_SUFFIX_REGEX, "");
Expand Down
69 changes: 69 additions & 0 deletions apps/researcher/lib/auth/delegate-account.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import * as ed from "@noble/ed25519";
import { sha512 } from "@noble/hashes/sha512";
import { enokiConfig } from "@/lib/enoki/config";

if (!ed.etc.sha512Sync) {
ed.etc.sha512Sync = (...m: Uint8Array[]) => {
const h = sha512.create();
for (const msg of m) h.update(msg);
return h.digest();
};
}

/**
* Fixture identities for Playwright runs (lib/constants.ts isTestEnvironment).
*
* Each account id maps to exactly one delegate private key, so the real
* binding validation in delegate-account.ts still runs meaningfully against
* the fabricated object: an unknown account id fails lookup, and a key that
* doesn't derive the registered public key is rejected — the same failure
* modes as the on-chain path.
*
* Mirrored in tests/playwright/fixtures/test-accounts.ts — keep in sync.
*/
const TEST_DELEGATE_ACCOUNTS: ReadonlyArray<{
accountId: string;
privateKey: string;
}> = [
{ accountId: `0x${"aa".repeat(32)}`, privateKey: "a".repeat(64) },
{ accountId: `0x${"bb".repeat(32)}`, privateKey: "b".repeat(64) },
];

function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}

function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

/**
* Fabricated stand-in for the Sui gRPC getObject response, shaped exactly
* like the fields delegate-account.ts validates. Returns null for account
* ids outside the fixture list (the "object not found" case).
*/
export function mockDelegateAccountObject(
accountId: string
): { type: string; json: unknown } | null {
const fixture = TEST_DELEGATE_ACCOUNTS.find(
(account) => account.accountId.toLowerCase() === accountId.toLowerCase()
);
if (!fixture) {
return null;
}

const publicKey = bytesToHex(ed.getPublicKey(hexToBytes(fixture.privateKey)));
return {
type: `${enokiConfig.memwalPackageId}::account::MemWalAccount`,
json: {
active: true,
delegate_keys: [{ public_key: publicKey }],
},
};
}
22 changes: 22 additions & 0 deletions apps/researcher/lib/auth/delegate-account.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { SuiGrpcClient } from "@mysten/sui/grpc";
import { fromBase64, normalizeSuiAddress, toHex } from "@mysten/sui/utils";
import { isTestEnvironment } from "@/lib/constants";
import { enokiConfig } from "@/lib/enoki/config";

export class DelegateAccountBindingError extends Error {
Expand Down Expand Up @@ -85,6 +86,27 @@ export async function assertDelegateAccountBinding(input: {
);
}

if (isTestEnvironment) {
// Playwright runs have no chain to read. Serve a fixture object instead
// of the gRPC fetch so the real validation below still executes — an
// unknown account or unregistered key fails the same way it would live.
const { mockDelegateAccountObject } = await import(
"./delegate-account.mock"
);
const mocked = mockDelegateAccountObject(input.accountId);
if (!mocked) {
throw new DelegateAccountBindingError(
"Unable to verify Walrus Memory account"
);
}
const mockError = delegateAccountBindingError(mocked.type, mocked.json, {
publicKeyHex: input.publicKeyHex,
packageId: enokiConfig.memwalPackageId,
});
if (mockError) throw new DelegateAccountBindingError(mockError);
return;
}

const network = enokiConfig.suiNetwork;
const client = new SuiGrpcClient({
network,
Expand Down
14 changes: 12 additions & 2 deletions apps/researcher/lib/sprint/memwal.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import "server-only";

import { MemWal } from "@mysten-incubation/memwal";
import { MemWal, MemWalMock } from "@mysten-incubation/memwal";
import type { RememberResult } from "@mysten-incubation/memwal";
import { isTestEnvironment } from "@/lib/constants";
import type { Citation, SourceMeta } from "./types";

function getMemWalClient(key: string, accountId: string) {
// Shared across requests so a sprint remembered in one Playwright request is
// recallable in the next — and so test runs can never write to the real
// relayer configured in MEMWAL_SERVER_URL.
let testMemWalClient: MemWalMock | null = null;

function getMemWalClient(key: string, accountId: string): MemWal | MemWalMock {
if (isTestEnvironment) {
testMemWalClient ??= MemWalMock.create({ owner: "playwright" });
return testMemWalClient;
}
return MemWal.create({
key,
accountId,
Expand Down
5 changes: 4 additions & 1 deletion apps/researcher/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
"verify:memwal": "tsx ../../scripts/verify-memwal-credentials.ts",
"test:redis-outage": "node --conditions=react-server --test --import tsx lib/shared-redis.test.ts",
"test:unit": "node --test --import tsx 'lib/**/*.unit.test.ts'",
"test": "export PLAYWRIGHT=True && pnpm exec playwright test"
"test": "pnpm test:e2e",
"playwright:install": "playwright install --with-deps chromium",
"test:e2e": "PLAYWRIGHT=True playwright test",
"test:e2e:ui": "PLAYWRIGHT=True playwright test --ui"
},
"dependencies": {
"@ai-sdk/gateway": "^3.0.15",
Expand Down
Loading
Loading