Skip to content
Merged
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"worktree:setup": "node scripts/worktree.mjs setup",
"cloudflare:token": "node scripts/refresh-cloudflare-tunnel-token.mjs",
"prepare": "simple-git-hooks",
"lint": "pnpm file-length:check && pnpm test-architecture:check && pnpm dashboard-style:check && pnpm --filter @sentry/junior tool-annotations:check && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-linear lint && pnpm --filter @sentry/junior-vercel lint && pnpm ast-grep:lint && pnpm package:lint",
"lint": "pnpm file-length:check && pnpm test-architecture:check && pnpm dashboard-style:check && pnpm --filter @sentry/junior tool-annotations:check && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-linear lint && pnpm --filter @sentry/junior-vercel lint && pnpm --filter @sentry/junior-dashboard lint && pnpm ast-grep:lint && pnpm package:lint",
"lint:fix": "pnpm --filter @sentry/junior lint:fix",
"file-length:check": "node --test scripts/check-file-length.test.mjs && node scripts/check-file-length.mjs",
"test-architecture:check": "node --test scripts/check-test-architecture.test.mjs && node scripts/check-test-architecture.mjs",
Expand Down Expand Up @@ -40,6 +40,7 @@
"pre-commit": "pnpm lint-staged"
},
"lint-staged": {
"packages/junior-dashboard/src/**/*.{ts,tsx}": "pnpm --filter @sentry/junior-dashboard lint",
"packages/junior/**/*.{js,jsx,ts,tsx,mjs,cjs}": "pnpm --filter @sentry/junior exec oxlint --config .oxlintrc.json --deny-warnings --fix",
"*.{js,jsx,ts,tsx,mjs,cjs,json,md,mdx,yml,yaml}": [
"pnpm file-length:check",
Expand Down
13 changes: 13 additions & 0 deletions packages/junior-dashboard/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"jsPlugins": ["@tanstack/eslint-plugin-query"],
"rules": {
"@tanstack/query/exhaustive-deps": "error",
"@tanstack/query/infinite-query-property-order": "error",
"@tanstack/query/mutation-property-order": "error",
"@tanstack/query/no-rest-destructuring": "error",
"@tanstack/query/no-unstable-deps": "error",
"@tanstack/query/no-void-query-fn": "error",
"@tanstack/query/stable-query-client": "error"
}
}
26 changes: 26 additions & 0 deletions packages/junior-dashboard/e2e/conversations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,29 @@ test.beforeEach(async ({ page }) => {
await mockDashboardApis(page);
});

test("reuses the fresh conversation feed after window focus", async ({
page,
}) => {
let requests = 0;
await page.route("**/api/conversations?*", async (route) => {
requests += 1;
await route.fallback();
});

await page.goto(server.baseURL);
await expect(
page.getByRole("heading", { name: "Conversations" }),
).toBeVisible();
expect(requests).toBe(1);

await page.evaluate(() => {
window.dispatchEvent(new Event("visibilitychange"));
});
await page.waitForTimeout(100);

expect(requests).toBe(1);
});

test("opens a conversation in the built dashboard", async ({ page }) => {
await page.setViewportSize({ height: 900, width: 1600 });
const browserErrors = collectBrowserErrors(page);
Expand Down Expand Up @@ -362,6 +385,7 @@ test("inspects and copies an advisor transcript", async ({ context, page }) => {
test("archives and restores a conversation from the sidebar", async ({
page,
}) => {
const initialTime = Date.now();
await page.setViewportSize({ height: 900, width: 1600 });
let archived = false;
await page.route(/\/api\/conversations(?:\?.*)?$/, async (route) => {
Expand Down Expand Up @@ -414,6 +438,7 @@ test("archives and restores a conversation from the sidebar", async ({
response.request().method() === "GET" &&
/\/api\/conversations(?:\?.*)?$/.test(response.url()),
);
await page.clock.setFixedTime(new Date(initialTime + 31_000));
await page.evaluate(() => {
window.dispatchEvent(new Event("focus"));
window.dispatchEvent(new Event("visibilitychange"));
Expand Down Expand Up @@ -447,6 +472,7 @@ test("archives and restores a conversation from the sidebar", async ({
response.request().method() === "GET" &&
/\/api\/conversations(?:\?.*)?$/.test(response.url()),
);
await page.clock.setFixedTime(new Date(initialTime + 62_000));
await page.evaluate(() => {
window.dispatchEvent(new Event("focus"));
window.dispatchEvent(new Event("visibilitychange"));
Expand Down
233 changes: 233 additions & 0 deletions packages/junior-dashboard/e2e/personal-tokens.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { expect, test, type Page } from "@playwright/test";
import {
collectBrowserErrors,
type DashboardE2eServer,
mockDashboardApis,
startDashboardE2eServer,
} from "./harness";

let server: DashboardE2eServer;

test.beforeAll(async () => {
server = await startDashboardE2eServer();
});

test.afterAll(async () => {
await server.close();
});

test.beforeEach(async ({ page }) => {
await mockDashboardApis(page);
});

test("reuses the personal token list across dashboard routes", async ({
page,
}) => {
const browserErrors = collectBrowserErrors(page);
let listRequests = 0;
await page.route("**/api/personal-tokens", async (route) => {
listRequests += 1;
await route.fulfill({
json: {
tokens: [
{
createdAt: "2026-08-01T00:00:00.000Z",
expiresAt: "2026-10-30T00:00:00.000Z",
id: "00000000-0000-4000-8000-000000000001",
lastUsedAt: null,
name: "Local agent",
tokenSuffix: "abcd",
},
],
},
});
});

await page.goto(server.baseURL);
await openPersonalTokens(page);
await expect(page.getByText("Local agent", { exact: true })).toBeVisible();

await page.getByRole("link", { name: "System", exact: true }).click();
await expect(page).toHaveURL(`${server.baseURL}/system`);
await openPersonalTokens(page);

expect(listRequests).toBe(1);
expect(browserErrors).toEqual([]);
});

test("keeps a created token when a stale list refetch is in flight", async ({
page,
}) => {
const browserErrors = collectBrowserErrors(page);
const staleListStarted = promiseSignal();
const releaseStaleList = promiseSignal();
let listRequests = 0;
await page.route("**/api/personal-tokens", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
json: {
createdAt: "2026-08-01T00:01:00.000Z",
expiresAt: "2026-10-30T00:01:00.000Z",
id: "00000000-0000-4000-8000-000000000002",
lastUsedAt: null,
name: "Review token",
token: "jr_pat_one-time-secret",
tokenSuffix: "wxyz",
},
});
return;
}

listRequests += 1;
if (listRequests === 2) {
staleListStarted.resolve();
await releaseStaleList.promise;
}
await route
.fulfill({
json: {
tokens: [
{
createdAt: "2026-08-01T00:00:00.000Z",
expiresAt: "2026-10-30T00:00:00.000Z",
id: "00000000-0000-4000-8000-000000000001",
lastUsedAt: null,
name: "Local agent",
tokenSuffix: "abcd",
},
],
},
})
.catch(() => undefined);
});

await page.goto(server.baseURL);
await openPersonalTokens(page);
await expect(page.getByText("Local agent", { exact: true })).toBeVisible();

await page.getByRole("link", { name: "System", exact: true }).click();
await page.clock.setFixedTime(new Date(Date.now() + 31_000));
await openPersonalTokens(page);
await staleListStarted.promise;

await page.getByLabel("Token name").fill("Review token");
await page.getByRole("button", { name: "Create token" }).click();
await expect(page.getByText("jr_pat_one-time-secret")).toBeVisible();
releaseStaleList.resolve();

await expect(page.getByText("Review token", { exact: true })).toBeVisible();
expect(listRequests).toBe(2);
expect(browserErrors).toEqual([]);
});

test("starts only one token create for rapid clicks", async ({ page }) => {
const browserErrors = collectBrowserErrors(page);
const releaseCreate = promiseSignal();
let createRequests = 0;
await page.route("**/api/personal-tokens", async (route) => {
if (route.request().method() === "POST") {
createRequests += 1;
await releaseCreate.promise;
await route.fulfill({
json: {
createdAt: "2026-08-01T00:01:00.000Z",
expiresAt: "2026-10-30T00:01:00.000Z",
id: "00000000-0000-4000-8000-000000000002",
lastUsedAt: null,
name: "Review token",
token: "jr_pat_one-time-secret",
tokenSuffix: "wxyz",
},
});
return;
}

await route.fulfill({ json: { tokens: [] } });
});

await page.goto(server.baseURL);
await openPersonalTokens(page);
await page.getByLabel("Token name").fill("Review token");
await page
.getByRole("button", { name: "Create token" })
.evaluate((button) => {
button.click();
button.click();
});
await expect.poll(() => createRequests).toBe(1);
releaseCreate.resolve();

await expect(page.getByText("jr_pat_one-time-secret")).toBeVisible();
expect(createRequests).toBe(1);
expect(browserErrors).toEqual([]);
});

test("keeps cached tokens and mutation errors after a refetch fails", async ({
page,
}) => {
const backgroundRefetchFinished = promiseSignal();
let listRequests = 0;
await page.route("**/api/personal-tokens", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({ status: 500 });
return;
}

listRequests += 1;
if (listRequests === 2) {
await route.fulfill({ status: 500 });
backgroundRefetchFinished.resolve();
return;
}
await route.fulfill({
json: {
tokens: [
{
createdAt: "2026-08-01T00:00:00.000Z",
expiresAt: "2026-10-30T00:00:00.000Z",
id: "00000000-0000-4000-8000-000000000001",
lastUsedAt: null,
name: "Local agent",
tokenSuffix: "abcd",
},
],
},
});
});

await page.goto(server.baseURL);
await openPersonalTokens(page);
await expect(page.getByText("Local agent", { exact: true })).toBeVisible();

await page.getByRole("link", { name: "System", exact: true }).click();
await page.clock.setFixedTime(new Date(Date.now() + 31_000));
await openPersonalTokens(page);
await backgroundRefetchFinished.promise;
await expect(page.getByText("Local agent", { exact: true })).toBeVisible();

await page.getByLabel("Token name").fill("Review token");
await page.getByRole("button", { name: "Create token" }).click();
await expect(
page.getByText("Could not create the API token. Try again."),
).toBeVisible();
await expect(
page.getByText("Could not load API tokens. Try again."),
).toHaveCount(0);
expect(listRequests).toBe(2);
});

async function openPersonalTokens(page: Page) {
await page.getByRole("button", { name: /Open profile menu/ }).click();
await page.getByRole("link", { name: "API tokens", exact: true }).click();
await expect(
page.getByRole("heading", { name: "Personal API Tokens" }),
).toBeVisible();
}

function promiseSignal() {
let resolve!: () => void;
const promise = new Promise<void>((complete) => {
resolve = complete;
});
return { promise, resolve };
}
5 changes: 5 additions & 0 deletions packages/junior-dashboard/e2e/system.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@ test.beforeEach(async ({ page }) => {
test("shows system usage and plugin details", async ({ page }) => {
await page.setViewportSize({ height: 900, width: 1600 });
const browserErrors = collectBrowserErrors(page);
let identityRequests = 0;
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/me") identityRequests += 1;
});
await page.goto(`${server.baseURL}/system`);

await expect(page.getByText("Usage over time")).toBeVisible();
expect(identityRequests).toBe(1);
await expect(page.getByText("Model spend")).toBeVisible();
await expect(page.getByRole("region", { name: "Plugins" })).toHaveCount(0);

Expand Down
10 changes: 9 additions & 1 deletion packages/junior-dashboard/e2e/user-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ test("searches, paginates, and forgets plugin page records", async ({
page,
}) => {
let forgotMemory = false;
let forgetRequests = 0;
let dashboardRequestCount = 0;
await page.route("**/api/plugins/memory/dashboard", async (route) => {
dashboardRequestCount += 1;
Expand Down Expand Up @@ -231,6 +232,7 @@ test("searches, paginates, and forgets plugin page records", async ({
await page.route(
"**/api/plugins/memory/memories/memory-search",
async (route) => {
forgetRequests += 1;
expect(route.request().method()).toBe("DELETE");
forgotMemory = true;
await route.fulfill({ status: 204 });
Expand Down Expand Up @@ -278,10 +280,16 @@ test("searches, paginates, and forgets plugin page records", async ({
await page
.getByRole("button", { name: /^Deploy runbooks live in Notion/ })
.click();
await page.getByRole("button", { name: "Forget this memory" }).click();
await page
.getByRole("button", { name: "Forget this memory" })
.evaluate((button) => {
button.click();
button.click();
});
await expect(
page.getByText("No memories matched your search."),
).toBeVisible();
expect(forgetRequests).toBe(1);
await expect.poll(() => dashboardRequestCount).toBeGreaterThan(1);

await searchbox.fill("");
Expand Down
4 changes: 4 additions & 0 deletions packages/junior-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"build:client": "tsup --config tsup.client.config.ts",
"build:css": "tailwindcss -i src/tailwind.css -o dist/tailwind.css --minify",
"build:server": "tsup --config tsup.config.ts",
"lint": "oxlint --config .oxlintrc.json --deny-warnings src",
"prepare": "pnpm run build",
"prepack": "pnpm run build",
"test": "vitest run -c vitest.config.ts",
Expand All @@ -49,10 +50,13 @@
},
"devDependencies": {
"@tailwindcss/cli": "^4.3.0",
"@tanstack/eslint-plugin-query": "^5.101.4",
"@types/node": "^25.9.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitest/coverage-v8": "4.1.7",
"eslint": "^10.8.0",
"oxlint": "^1.66.0",
"tailwindcss": "^4.3.0",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
Expand Down
Loading
Loading