Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
48 changes: 47 additions & 1 deletion app/packages/web/e2e/fixtures/game-data.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { GameStatus, CostEstimates, EnvInfo, WatchdogConfig, ActualCosts } from '@/api.js';
import type {
GameStatus,
CostEstimates,
EnvInfo,
WatchdogConfig,
ActualCosts,
DiscordConfigRedacted,
} from '@/api.js';

/** Stub response for `GET /api/env`. */
export const ENV_DATA: EnvInfo = {
Expand Down Expand Up @@ -108,3 +115,42 @@ export function makeActualCosts(days: number): ActualCosts {
const total = daily.reduce((sum, d) => sum + d.cost, 0);
return { daily, total: Math.round(total * 100) / 100, currency: 'USD', days };
}

/** A valid Discord snowflake (17–20 digits) for use in test inputs. */
export const VALID_GUILD_ID = '123456789012345678';
/** A second valid snowflake — useful for multi-guild specs. */
export const VALID_GUILD_ID_2 = '987654321098765432';
/** A valid user-shaped snowflake for admin/permission specs. */
export const VALID_USER_ID = '111122223333444455';

/**
* First-run Discord config — no guilds, no admins, no secrets configured.
* Triggers the `/discord` setup-wizard render path.
*/
export const FIRST_RUN_DISCORD_CONFIG: DiscordConfigRedacted = {
clientId: '',
allowedGuilds: [],
admins: { userIds: [], roleIds: [] },
gamePermissions: {},
baseAllowedGuilds: [],
baseAdmins: { userIds: [], roleIds: [] },
botTokenSet: false,
publicKeySet: false,
interactionsEndpointUrl: null,
};

/**
* Fully-configured Discord config — bot token + public key set, one allowlisted
* guild, one admin user. Used to exercise the post-setup tabs.
*/
export const CONFIGURED_DISCORD_CONFIG: DiscordConfigRedacted = {
clientId: '111111111111111111',
allowedGuilds: [VALID_GUILD_ID],
admins: { userIds: [VALID_USER_ID], roleIds: [] },
gamePermissions: {},
baseAllowedGuilds: [],
baseAdmins: { userIds: [], roleIds: [] },
botTokenSet: true,
publicKeySet: true,
interactionsEndpointUrl: 'https://abc123.lambda-url.us-east-1.on.aws/',
};
68 changes: 65 additions & 3 deletions app/packages/web/e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
import { test as base, type Page } from '@playwright/test';
import type { GameStatus, CostEstimates, EnvInfo, ActionResult, WatchdogConfig, ActualCosts } from '@/api.js';
import { ENV_DATA, STOPPED_GAME, COST_DATA, WATCHDOG_CONFIG, makeActualCosts } from './game-data.js';
import type {
GameStatus,
CostEstimates,
EnvInfo,
ActionResult,
WatchdogConfig,
ActualCosts,
DiscordConfigRedacted,
} from '@/api.js';
import {
ENV_DATA,
STOPPED_GAME,
COST_DATA,
WATCHDOG_CONFIG,
CONFIGURED_DISCORD_CONFIG,
makeActualCosts,
} from './game-data.js';
import { AppLayout, AuthGatePage, DashboardPage, CostsPage } from '../pages/index.js';

export type { GameStatus, CostEstimates, EnvInfo, WatchdogConfig, ActualCosts };
export type {
GameStatus,
CostEstimates,
EnvInfo,
WatchdogConfig,
ActualCosts,
DiscordConfigRedacted,
};
export {
ENV_DATA,
STOPPED_GAME,
Expand All @@ -14,6 +36,11 @@ export {
WATCHDOG_CONFIG,
ACTUAL_COSTS,
makeActualCosts,
FIRST_RUN_DISCORD_CONFIG,
CONFIGURED_DISCORD_CONFIG,
VALID_GUILD_ID,
VALID_GUILD_ID_2,
VALID_USER_ID,
} from './game-data.js';
export { AppLayout, AuthGatePage, DashboardPage, CostsPage } from '../pages/index.js';

Expand All @@ -36,6 +63,13 @@ export interface StubOptions {
config?: WatchdogConfig;
/** Override for `POST /api/start/:game` response. */
startResult?: ActionResult;
/**
* Discord config returned by `GET /api/discord/config`. Defaults to
* `CONFIGURED_DISCORD_CONFIG` so non-Discord specs hitting `/discord` (e.g.
* sidebar nav) don't trip the catch-all 404 handler. Pass
* `FIRST_RUN_DISCORD_CONFIG` to exercise the setup wizard.
*/
discord?: DiscordConfigRedacted;
}

/**
Expand All @@ -54,6 +88,8 @@ export async function stubApis(page: Page, opts: StubOptions = {}): Promise<void
const env = opts.env ?? ENV_DATA;
const config = opts.config ?? WATCHDOG_CONFIG;
const startResult: ActionResult = opts.startResult ?? { success: true, message: 'Started' };
const discord = opts.discord ?? CONFIGURED_DISCORD_CONFIG;
const games = statuses.map((s) => s.game);
const actualCostsFn: (days: number) => ActualCosts =
typeof opts.actualCosts === 'function'
? opts.actualCosts
Expand All @@ -75,6 +111,8 @@ export async function stubApis(page: Page, opts: StubOptions = {}): Promise<void
return route.fulfill({ json: s });
});

await page.route('**/api/games', (route) => route.fulfill({ json: { games } }));

await page.route('**/api/costs/estimate', (route) => route.fulfill({ json: costs }));

// Trailing `*` matches the `?days=N` query string — Playwright globs are
Expand All @@ -97,6 +135,30 @@ export async function stubApis(page: Page, opts: StubOptions = {}): Promise<void
await page.route('**/api/stop/*', (route) =>
route.fulfill({ json: { success: true, message: 'Stopped' } as ActionResult })
);

// Discord — read endpoint plus permissive write endpoints. Specs that need
// to assert request bodies should override these with their own page.route().
await page.route('**/api/discord/config', (route) => {
if (route.request().method() === 'PUT') {
return route.fulfill({ json: { success: true, config: discord } });
}
return route.fulfill({ json: discord });
});
await page.route('**/api/discord/guilds', (route) =>
route.fulfill({ json: { success: true, guilds: discord.allowedGuilds } }),
);
await page.route('**/api/discord/guilds/*', (route) =>
route.fulfill({ json: { success: true, guilds: discord.allowedGuilds } }),
);
await page.route('**/api/discord/guilds/*/register-commands', (route) =>
route.fulfill({ json: { success: true, message: 'Registered' } }),
);
await page.route('**/api/discord/admins', (route) =>
route.fulfill({ json: { success: true, admins: discord.admins } }),
);
await page.route('**/api/discord/permissions/*', (route) =>
route.fulfill({ json: { success: true, permissions: discord.gamePermissions } }),
);
}

type E2EFixtures = {
Expand Down
222 changes: 222 additions & 0 deletions app/packages/web/e2e/specs/discord.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import {
test,
expect,
stubApis,
CONFIGURED_DISCORD_CONFIG,
FIRST_RUN_DISCORD_CONFIG,
MULTI_GAME_STATUSES,
STOPPED_GAME,
VALID_GUILD_ID,
VALID_GUILD_ID_2,
} from '../fixtures/index.js';

test.describe('discord settings', () => {
test('should show the setup wizard when no guilds and no bot token are configured', async ({
authedPage: page,
}) => {
await stubApis(page, { discord: FIRST_RUN_DISCORD_CONFIG });
await page.goto('/discord');

await expect(page.getByRole('heading', { name: 'Get started' })).toBeVisible();
await expect(page.getByRole('link', { name: /developers\/applications/i })).toBeVisible();
});

test('should hide the setup wizard once a guild is allowlisted', async ({
authedPage: page,
}) => {
await stubApis(page, { discord: CONFIGURED_DISCORD_CONFIG });
await page.goto('/discord');

await expect(page.getByRole('heading', { name: 'Discord' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Get started' })).not.toBeVisible();
});

test('should render the Credentials tab by default', async ({ authedPage: page }) => {
await stubApis(page, { discord: CONFIGURED_DISCORD_CONFIG });
await page.goto('/discord');

await expect(page.getByLabel('Application (Client) ID')).toBeVisible();
await expect(page.getByRole('button', { name: 'Save credentials' })).toBeVisible();
});

test('should show a "set" indicator when the bot token is already configured', async ({
authedPage: page,
}) => {
await stubApis(page, { discord: CONFIGURED_DISCORD_CONFIG });
await page.goto('/discord');

// Both the green-check badge (aria-label) and the helper text render when
// the secret is already set server-side.
await expect(page.locator('[aria-label="Already set"]').first()).toBeVisible();
await expect(page.getByText('Already set — leave blank to keep').first()).toBeVisible();
});

test('should toggle bot-token visibility when the eye icon is clicked', async ({
authedPage: page,
}) => {
await stubApis(page, { discord: CONFIGURED_DISCORD_CONFIG });
await page.goto('/discord');

const tokenField = page.locator('#bot-token');
await expect(tokenField).toHaveAttribute('type', 'password');

await page.getByRole('button', { name: 'Show value' }).first().click();
await expect(tokenField).toHaveAttribute('type', 'text');

await page.getByRole('button', { name: 'Hide value' }).first().click();
await expect(tokenField).toHaveAttribute('type', 'password');
});

test('should never echo the bot token or public key in the config response', async ({
authedPage: page,
}) => {
await stubApis(page, { discord: CONFIGURED_DISCORD_CONFIG });

const responsePromise = page.waitForResponse('**/api/discord/config');
await page.goto('/discord');
const response = await responsePromise;

const body = (await response.json()) as Record<string, unknown>;
expect(body).not.toHaveProperty('botToken');
expect(body).not.toHaveProperty('publicKey');
expect(body).toHaveProperty('botTokenSet');
expect(body).toHaveProperty('publicKeySet');
});

test('should switch to the Guilds tab when clicked', async ({ authedPage: page }) => {
await stubApis(page, { discord: CONFIGURED_DISCORD_CONFIG });
await page.goto('/discord');

await page.getByRole('tab', { name: 'Guilds' }).click();
await expect(page.getByLabel('Add a guild')).toBeVisible();
});

test('should reject a malformed guild snowflake with an inline error', async ({
authedPage: page,
}) => {
await stubApis(page, { discord: CONFIGURED_DISCORD_CONFIG });

let addCalled = false;
await page.route('**/api/discord/guilds', (route) => {
if (route.request().method() === 'POST') addCalled = true;
return route.fulfill({
json: { success: true, guilds: CONFIGURED_DISCORD_CONFIG.allowedGuilds },
});
});

await page.goto('/discord');
await page.getByRole('tab', { name: 'Guilds' }).click();

await page.getByLabel('Add a guild').fill('not-a-snowflake');
await page.getByRole('button', { name: 'Add' }).click();

await expect(page.getByText(/17.20 digit Discord snowflakes/i)).toBeVisible();
expect(addCalled).toBe(false);
});

test('should POST a valid snowflake to /api/discord/guilds', async ({ authedPage: page }) => {
await stubApis(page, { discord: CONFIGURED_DISCORD_CONFIG });

let postedBody: Record<string, unknown> | null = null;
await page.route('**/api/discord/guilds', async (route) => {
if (route.request().method() === 'POST') {
postedBody = route.request().postDataJSON() as Record<string, unknown>;
}
await route.fulfill({
json: { success: true, guilds: [...CONFIGURED_DISCORD_CONFIG.allowedGuilds, VALID_GUILD_ID_2] },
});
});

await page.goto('/discord');
await page.getByRole('tab', { name: 'Guilds' }).click();
await page.getByLabel('Add a guild').fill(VALID_GUILD_ID_2);
await page.getByRole('button', { name: 'Add' }).click();

await expect.poll(() => postedBody).toEqual({ guildId: VALID_GUILD_ID_2 });
});

test('should list configured guilds in the Guilds table', async ({ authedPage: page }) => {
await stubApis(page, { discord: CONFIGURED_DISCORD_CONFIG });
await page.goto('/discord');
await page.getByRole('tab', { name: 'Guilds' }).click();

for (const id of CONFIGURED_DISCORD_CONFIG.allowedGuilds) {
await expect(page.getByRole('cell', { name: id })).toBeVisible();
}
});

test('should mark a guild as registered after a successful register-commands call', async ({
authedPage: page,
}) => {
await stubApis(page, { discord: CONFIGURED_DISCORD_CONFIG });
await page.goto('/discord');
await page.getByRole('tab', { name: 'Guilds' }).click();

const row = page.getByRole('row').filter({ hasText: VALID_GUILD_ID });
await expect(row.getByText('not registered')).toBeVisible();

await row.getByRole('button', { name: 'Register' }).click();

await expect(row.getByText('registered', { exact: true })).toBeVisible();
await expect(row.getByText('not registered')).toHaveCount(0);
});

test('should leave a guild not-registered when register-commands fails', async ({
authedPage: page,
}) => {
await stubApis(page, { discord: CONFIGURED_DISCORD_CONFIG });

// Override the success stub from stubApis so the register-commands call
// returns 500 and the wrap()-driven Promise rejects. Tracks the call so
// the assertion can wait until after the failure resolves.
let registerCalled = false;
await page.route('**/api/discord/guilds/*/register-commands', (route) => {
registerCalled = true;
return route.fulfill({ status: 500, json: { error: 'discord rejected' } });
});

await page.goto('/discord');
await page.getByRole('tab', { name: 'Guilds' }).click();

const row = page.getByRole('row').filter({ hasText: VALID_GUILD_ID });
await expect(row.getByText('not registered')).toBeVisible();

await row.getByRole('button', { name: 'Register' }).click();

await expect.poll(() => registerCalled).toBe(true);

// Badge must stay in the not-registered state — the optimistic-success
// flip would otherwise lie about a registration that never happened.
await expect(row.getByText('not registered')).toBeVisible();
await expect(row.getByText('registered', { exact: true })).toHaveCount(0);
});

test('should render a row per game in the per-game permissions table', async ({
authedPage: page,
}) => {
await stubApis(page, {
discord: CONFIGURED_DISCORD_CONFIG,
statuses: MULTI_GAME_STATUSES,
});
await page.goto('/discord');
await page.getByRole('tab', { name: 'Per-Game Permissions' }).click();

for (const s of MULTI_GAME_STATUSES) {
await expect(page.getByRole('cell', { name: s.game, exact: true })).toBeVisible();
}
});

test('should show the not-deployed empty state when /api/discord/config 404s', async ({
authedPage: page,
}) => {
// Override stubApis so /api/discord/config returns 404 — the page should
// surface the friendly "infrastructure not deployed yet" state.
await stubApis(page, { statuses: [STOPPED_GAME] });
await page.route('**/api/discord/config', (route) =>
route.fulfill({ status: 404, json: { error: 'not deployed' } }),
);

await page.goto('/discord');
await expect(page.getByText(/infrastructure not deployed yet/i)).toBeVisible();
});
});
Loading
Loading