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
7 changes: 4 additions & 3 deletions docs/src/content/docs/getting-started/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,11 @@ hijack your panel.

## Step 4 — Register your first game server

In the panel, navigate to **Admin Panel → Server Settings → Add new
In the panel, navigate to **Admin Panel → Servers → Add new
server**. Fill in the form (game, RCON password, IP, port), click
**Add Server**, and note the **`ID` column** for the new row. That
number is the `ServerID` the plugin needs in the next step.
**Add Server**, and note the **`Server ID`** shown on the new
server's card. That number is the `ServerID` the plugin needs in the
next step.

Full walkthrough with screenshots:
[Adding a server](/setup/adding-server/).
Expand Down
11 changes: 6 additions & 5 deletions docs/src/content/docs/setup/adding-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ a server without RCON access.
1. Sign into the web panel as an admin with the **Add server**
permission (owners have this by default).

2. Navigate to **Admin Panel → Server Settings → Add new server**.
2. Navigate to **Admin Panel → Servers → Add new server**.

3. Fill in:

Expand All @@ -43,9 +43,10 @@ a server without RCON access.

4. Click **Add server**.

5. After the page reloads, find the new row in the server list and
**note the `ID` column**. That number is the `ServerID` you'll
write into the SourceMod plugin's config in the next step.
5. After the page reloads, find the new server's card in the list and
**note its `Server ID`**. That number is the `ServerID` you'll
write into the SourceMod plugin's config in the next step. Use the
copy button next to it to grab the value.

The panel attempts an RCON connection right away to validate the
details. If it can't reach the server, you'll see a warning, but
Expand Down Expand Up @@ -95,7 +96,7 @@ If it doesn't:
:::tip
Adding a second or third server later? Repeat the same two halves
(panel registration + plugin `ServerID` edit) on each new server.
Each server gets its own row in the panel and its own `ServerID`.
Each server gets its own card in the panel and its own `ServerID`.
The `databases.cfg` section stays the same across all servers as
long as they share the same database.
:::
17 changes: 17 additions & 0 deletions web/tests/e2e/pages/admin/AdminServers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,23 @@ export class AdminServersPage extends BasePage {
return this.page.locator('script[src$="server-tile-hydrate.js"]');
}

/**
* The tile for a specific server, keyed on the stable
* `data-id="<sid>"` hook the hydration helper also routes on.
*/
tile(sid: number): Locator {
return this.page.locator(`[data-testid="server-tile"][data-id="${sid}"]`);
}

/**
* The "Server ID" `<dd>` inside a tile (#1504) — carries the
* numeric value span + the copy button. Operators need this to
* wire the SourceMod plugin's `sourcebans.cfg` `ServerID` field.
*/
serverIdRow(sid: number): Locator {
return this.tile(sid).locator('[data-testid="server-id"]');
}

async goto(): Promise<void> {
await super.goto(this.path);
}
Expand Down
105 changes: 105 additions & 0 deletions web/tests/e2e/specs/flows/admin-server-id.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Flow: the admin Server Management list surfaces each server's numeric
* Server ID on its card (#1504).
*
* Background
* ----------
* The SourceMod plugin's `sourcebans.cfg` carries a `ServerID` field
* whose inline comment reads "Check in the admin panel -> servers to
* find the ID of this server", and the setup docs tell operators to
* note the ID after adding a server. But the v2.0 card-grid rewrite of
* `page_admin_servers_list.tpl` never printed the sid anywhere the
* operator could read it (only inside RCON/Edit/Admins hrefs and the
* no-icon fallback glyph), so operators had no way to find the value
* the plugin needs. The reporter (issue #1504) hit exactly this wall.
*
* This spec seeds a server through the JSON API and asserts the tile
* renders a labelled, copyable "Server ID" row showing the raw sid.
* The clipboard round-trip itself is already exercised by the shared
* [data-copy] delegate spec (`flows/ui/copy-buttons.spec.ts`), so this
* spec locks the render contract only.
*
* It also runs the critical-axe gate on the POPULATED card. The
* sibling smoke spec (`smoke/admin/servers.spec.ts`) deliberately
* never seeds a row, so its axe pass only covers the empty state —
* the new icon-only copy button + Server ID row only exist once a
* tile renders, and this is the only spec that puts axe on them.
*
* Selectors are `data-testid` per #1123. Single-project (chromium)
* because it drives `truncateE2eDb()` — same DB-isolation rationale as
* `server-refresh-debounce.spec.ts` (see that file's comment for the
* truncate-vs-Apache race against the shared `sourcebans_e2e` DB).
*/

import { expect, test } from '../../fixtures/auth.ts';
import { expectNoCriticalA11y } from '../../fixtures/axe.ts';
import { truncateE2eDb } from '../../fixtures/db.ts';
import { AdminServersPage } from '../../pages/admin/AdminServers.ts';

test.describe('flow: admin servers — Server ID is discoverable (#1504)', () => {
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name !== 'chromium',
'Browser-shape-agnostic server-rendered markup; skip the second project to avoid the truncate-vs-Apache race against sourcebans_e2e (see server-refresh-debounce.spec.ts).',
);
});

test('the server card shows the numeric Server ID with a copy button', async ({ page }, testInfo) => {
await truncateE2eDb();

// Seed a server via the JSON API. RFC 5737 documentation IP so
// no real Source server can ever answer the A2S probe — this
// spec only cares about the server-rendered Server ID, not the
// live hydration cells.
await page.goto('/index.php?p=admin&c=servers&section=add');
const addEnvelope = await page.evaluate(async () => {
const w = window as unknown as {
sb: { api: { call: (a: string, p: Record<string, unknown>) => Promise<{ ok: boolean }> } };
Actions: Record<string, string>;
};
return await w.sb.api.call(w.Actions.ServersAdd, {
ip: '203.0.113.7',
port: '27015',
rcon: '',
rcon2: '',
mod: 1,
enabled: true,
group: '0',
});
});
expect(addEnvelope, 'servers.add envelope should round-trip ok').toMatchObject({ ok: true });

const p = new AdminServersPage(page);
await p.goto();
await expect(p.pageMounted).toBeVisible();

// Resolve the seeded sid from the DOM without relying on the
// API envelope shape (that is not the contract under test):
// there is exactly one tile after the truncate + single seed.
const seededTile = page.locator('[data-testid="server-tile"][data-id]').first();
await expect(seededTile).toBeVisible();
const sidAttr = await seededTile.getAttribute('data-id');
expect(sidAttr, 'seeded tile must carry a data-id sid').toBeTruthy();
const sid = Number(sidAttr);

// The labelled Server ID row is visible and prints the sid.
const idRow = p.serverIdRow(sid);
await expect(idRow).toBeVisible();
await expect(idRow.locator('[data-testid="server-id-value"]')).toHaveText(String(sid));

// The copy button carries the sid as its clipboard payload so
// the shared [data-copy] delegate can write it verbatim.
const copyBtn = idRow.locator('[data-testid="server-id-copy"]');
await expect(copyBtn).toBeVisible();
await expect(copyBtn).toHaveAttribute('data-copy', String(sid));

// The literal label anchors discoverability — the whole point
// of the fix is that "Server ID" is written on the card.
await expect(p.tile(sid)).toContainText('Server ID');

// No critical a11y regressions on the populated card (the
// icon-only copy button carries aria-label + title; the smoke
// spec's axe pass only sees the empty state).
await expectNoCriticalA11y(page, testInfo);
});
});
48 changes: 48 additions & 0 deletions web/tests/integration/AdminServersListHydrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,54 @@ public function testDisabledTileSkipsHydration(): void
'Disabled tile must NOT render a refresh button — there is nothing to re-query (#1313).');
}

/**
* Issue #1504 — the SourceMod plugin's `sourcebans.cfg` and the
* setup docs both tell operators to "check the admin panel ->
* servers" to find the numeric `ServerID` a game server needs,
* but the card grid never surfaced it. Every enabled AND disabled
* tile must render a labelled, copyable "Server ID" row showing
* the raw sid so operators can wire the plugin without guessing.
*/
public function testTileRendersCopyableServerId(): void
{
$_GET = ['p' => 'admin', 'c' => 'servers', 'section' => 'list'];
$html = $this->renderAdminServersPage();

foreach ([$this->enabledSid, $this->disabledSid] as $sid) {
$tileHtml = $this->extractTile($html, $sid);

$this->assertStringContainsString('data-testid="server-id"', $tileHtml,
'Every tile must expose data-testid="server-id" so operators can find the ServerID the plugin needs (#1504).');
// The raw sid is rendered inside the value span.
$this->assertMatchesRegularExpression(
'/data-testid="server-id-value"[^>]*>' . $sid . '</',
$tileHtml,
"Tile for sid $sid must print the numeric Server ID in the value span (#1504)."
);
// The copy button feeds the document-level [data-copy]
// delegate in theme.js with the sid as its payload. Order-
// independent lookaheads so a future attribute reshuffle
// (data-copy vs data-testid ordering) doesn't false-fail.
$this->assertMatchesRegularExpression(
'/<button\b(?=[^>]*\bdata-testid="server-id-copy")(?=[^>]*\bdata-copy="' . $sid . '")/',
$tileHtml,
"Tile for sid $sid must ship a copy button carrying data-copy=\"$sid\" (#1504)."
);
// The human-readable label anchors discoverability — the
// whole point of the fix is that "Server ID" is written on
// the card, not just derivable from a URL. Assert the
// visible <dt>, not a bare substring: the copy button's
// title="Copy Server ID" / aria-label="Copy Server ID"
// would otherwise satisfy a substring match even if the
// label row were deleted.
$this->assertMatchesRegularExpression(
'/<dt[^>]*>Server ID<\/dt>/',
$tileHtml,
"Tile for sid $sid must carry the visible 'Server ID' <dt> label (#1504)."
);
}
}

/**
* The View DTO and handler must continue to feed the template the
* fields the markup contract depends on. This is a belt-and-braces
Expand Down
24 changes: 23 additions & 1 deletion web/themes/default/page_admin_servers_list.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,33 @@
<span data-status-label>Loading</span>
</span>
{else}
<span class="pill pill--offline" title="Disabled hidden from public lists">Disabled</span>
<span class="pill pill--offline" title="Disabled (hidden from public lists)">Disabled</span>
{/if}
</header>

<dl class="text-xs text-muted" style="margin:0;display:grid;grid-template-columns:auto 1fr;gap:0.25rem 0.5rem">
{*
Server ID (#1504): the numeric sid the SourceMod
plugin's sourcebans.cfg "ServerID" field needs.
The plugin config + docs both tell operators to
"check the admin panel -> servers" for it, but the
card grid never surfaced it — this labelled,
copyable row is the fix. The copy button rides the
document-level [data-copy] delegate in theme.js
(secure-context clipboard + execCommand fallback).
*}
<dt style="font-weight:500;color:var(--text)">Server ID</dt>
<dd style="margin:0;display:flex;align-items:center;gap:0.25rem" data-testid="server-id">
<span class="font-mono" data-testid="server-id-value">{$server.sid}</span>
<button class="btn btn--ghost btn--icon btn--xs"
type="button"
data-copy="{$server.sid}"
data-testid="server-id-copy"
title="Copy Server ID"
aria-label="Copy Server ID">
<i data-lucide="copy" style="width:12px;height:12px"></i>
</button>
</dd>
<dt style="font-weight:500;color:var(--text)">Mod</dt>
<dd style="margin:0">{if isset($server.mod_name)}{$server.mod_name|escape}{else}<span class="text-faint">unknown</span>{/if}</dd>
<dt style="font-weight:500;color:var(--text)">Players</dt>
Expand Down
Loading