Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
E2E Playwright / Playwright e2e (chromium) (push) Waiting to run
7 scenarios covering the full bridge+DocAdenice+Baserow chain: auth login, database-view insert, inline edit persistence, SSE realtime update (no reload), RBAC write-denied, kanban drag-drop, calendar reschedule. Includes docker-compose.e2e.yml (Postgres+Redis+Baserow+bridge+DocAdenice), playwright.config.ts (3 projects: chromium/firefox/webkit), auth+baserow+cleanup fixtures, global setup (API login + Baserow seed), and GitHub Actions e2e.yml. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
571 lines
16 KiB
TypeScript
571 lines
16 KiB
TypeScript
/**
|
|
* Baserow pre-seed fixtures for e2e tests.
|
|
*
|
|
* Creates a minimal Baserow workspace + database + table + view + rows
|
|
* that the e2e tests can target. All entities are created via the Baserow REST
|
|
* API using the admin token obtained during the bootstrap sequence.
|
|
*
|
|
* Design choices:
|
|
* - Idempotent: checks for existence before creating (lookup by name).
|
|
* - Self-contained: all IDs are returned so tests do not have to hardcode them.
|
|
* - Cleanup: the cleanup fixture deletes everything created here to avoid test pollution.
|
|
*/
|
|
|
|
import { request, type APIRequestContext } from "@playwright/test";
|
|
|
|
const BASEROW_URL = process.env.E2E_BASEROW_URL ?? "http://localhost:8081";
|
|
|
|
export interface BaserowSeed {
|
|
workspaceId: number;
|
|
databaseId: number;
|
|
tableId: number;
|
|
gridViewId: number;
|
|
kanbanViewId: number;
|
|
calendarViewId: number;
|
|
/** Admin JWT for direct Baserow API calls. */
|
|
token: string;
|
|
/** Pre-seeded row IDs (5 rows). */
|
|
rowIds: number[];
|
|
/** Name of the single-select field used for kanban grouping. */
|
|
singleSelectFieldName: string;
|
|
/** Name of the date field used for calendar positioning. */
|
|
dateFieldName: string;
|
|
/** Name of the primary text field. */
|
|
primaryFieldName: string;
|
|
}
|
|
|
|
export interface BaserowAdminCredentials {
|
|
email: string;
|
|
password: string;
|
|
}
|
|
|
|
export const baserowAdminCredentials: BaserowAdminCredentials = {
|
|
email: process.env.E2E_BASEROW_ADMIN_EMAIL ?? "admin@acadenice-e2e.local",
|
|
password: process.env.E2E_BASEROW_ADMIN_PASSWORD ?? "E2eAdminPassword123!",
|
|
};
|
|
|
|
/**
|
|
* Login to Baserow and return the JWT token.
|
|
*/
|
|
async function loginBaserow(
|
|
apiContext: APIRequestContext,
|
|
credentials: BaserowAdminCredentials,
|
|
): Promise<string> {
|
|
const response = await apiContext.post(
|
|
`${BASEROW_URL}/api/user/token-auth/`,
|
|
{
|
|
data: credentials,
|
|
headers: { "Content-Type": "application/json" },
|
|
},
|
|
);
|
|
|
|
if (!response.ok()) {
|
|
const body = await response.text();
|
|
throw new Error(`Baserow login failed: HTTP ${response.status()} — ${body}`);
|
|
}
|
|
|
|
const data = (await response.json()) as { token?: string; access_token?: string };
|
|
const token = data.token ?? data.access_token;
|
|
if (!token) {
|
|
throw new Error(`Baserow login: no token in response ${JSON.stringify(data)}`);
|
|
}
|
|
return token;
|
|
}
|
|
|
|
/**
|
|
* Register the first Baserow admin account (only works on a fresh instance).
|
|
*/
|
|
async function registerBaserowAdmin(
|
|
apiContext: APIRequestContext,
|
|
credentials: BaserowAdminCredentials,
|
|
): Promise<string> {
|
|
const response = await apiContext.post(
|
|
`${BASEROW_URL}/api/user/`,
|
|
{
|
|
data: {
|
|
email: credentials.email,
|
|
password: credentials.password,
|
|
name: "E2E Admin",
|
|
authenticate: true,
|
|
},
|
|
headers: { "Content-Type": "application/json" },
|
|
},
|
|
);
|
|
|
|
if (!response.ok()) {
|
|
const body = await response.text();
|
|
throw new Error(
|
|
`Baserow admin registration failed: HTTP ${response.status()} — ${body}`,
|
|
);
|
|
}
|
|
|
|
const data = (await response.json()) as { token?: string; access_token?: string };
|
|
return data.token ?? data.access_token ?? "";
|
|
}
|
|
|
|
/**
|
|
* Obtain a Baserow JWT — register on first boot, login on subsequent boots.
|
|
*/
|
|
export async function getBaserowToken(
|
|
apiContext: APIRequestContext,
|
|
): Promise<string> {
|
|
// Try login first (most common case on subsequent runs).
|
|
try {
|
|
return await loginBaserow(apiContext, baserowAdminCredentials);
|
|
} catch {
|
|
// Login failed — probably a fresh Baserow instance with no users.
|
|
try {
|
|
const token = await registerBaserowAdmin(apiContext, baserowAdminCredentials);
|
|
if (token) return token;
|
|
// Registration succeeded but login required on a separate call.
|
|
return await loginBaserow(apiContext, baserowAdminCredentials);
|
|
} catch (registerError) {
|
|
throw new Error(
|
|
`Cannot obtain Baserow token: ${registerError instanceof Error ? registerError.message : String(registerError)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure a Baserow workspace named "E2E Workspace" exists and return its ID.
|
|
*/
|
|
async function ensureWorkspace(
|
|
apiContext: APIRequestContext,
|
|
token: string,
|
|
): Promise<number> {
|
|
const headers = { Authorization: `JWT ${token}` };
|
|
|
|
const listResponse = await apiContext.get(`${BASEROW_URL}/api/workspaces/`, { headers });
|
|
if (!listResponse.ok()) {
|
|
throw new Error(`List workspaces failed: ${listResponse.status()}`);
|
|
}
|
|
|
|
const workspaces = (await listResponse.json()) as Array<{ id: number; name: string }>;
|
|
const existing = workspaces.find((w) => w.name === "E2E Workspace");
|
|
if (existing) return existing.id;
|
|
|
|
const createResponse = await apiContext.post(`${BASEROW_URL}/api/workspaces/`, {
|
|
headers: { ...headers, "Content-Type": "application/json" },
|
|
data: { name: "E2E Workspace" },
|
|
});
|
|
|
|
if (!createResponse.ok()) {
|
|
throw new Error(`Create workspace failed: ${createResponse.status()}`);
|
|
}
|
|
|
|
const created = (await createResponse.json()) as { id: number };
|
|
return created.id;
|
|
}
|
|
|
|
/**
|
|
* Ensure a database named "E2E Database" exists in the given workspace.
|
|
*/
|
|
async function ensureDatabase(
|
|
apiContext: APIRequestContext,
|
|
token: string,
|
|
workspaceId: number,
|
|
): Promise<number> {
|
|
const headers = { Authorization: `JWT ${token}` };
|
|
|
|
const listResponse = await apiContext.get(
|
|
`${BASEROW_URL}/api/applications/workspace/${workspaceId}/`,
|
|
{ headers },
|
|
);
|
|
|
|
if (listResponse.ok()) {
|
|
const apps = (await listResponse.json()) as Array<{
|
|
id: number;
|
|
name: string;
|
|
type: string;
|
|
}>;
|
|
const existing = apps.find(
|
|
(a) => a.name === "E2E Database" && a.type === "database",
|
|
);
|
|
if (existing) return existing.id;
|
|
}
|
|
|
|
const createResponse = await apiContext.post(`${BASEROW_URL}/api/applications/workspace/${workspaceId}/`, {
|
|
headers: { ...headers, "Content-Type": "application/json" },
|
|
data: { name: "E2E Database", type: "database" },
|
|
});
|
|
|
|
if (!createResponse.ok()) {
|
|
throw new Error(`Create database failed: ${createResponse.status()}`);
|
|
}
|
|
|
|
const created = (await createResponse.json()) as { id: number };
|
|
return created.id;
|
|
}
|
|
|
|
/**
|
|
* Ensure a table named "E2E Table" exists in the given database.
|
|
* Returns the table ID and the IDs of the seeded fields.
|
|
*/
|
|
async function ensureTable(
|
|
apiContext: APIRequestContext,
|
|
token: string,
|
|
databaseId: number,
|
|
): Promise<{
|
|
tableId: number;
|
|
primaryFieldId: number;
|
|
primaryFieldName: string;
|
|
singleSelectFieldId: number;
|
|
singleSelectFieldName: string;
|
|
dateFieldId: number;
|
|
dateFieldName: string;
|
|
}> {
|
|
const headers = { Authorization: `JWT ${token}` };
|
|
|
|
const listResponse = await apiContext.get(
|
|
`${BASEROW_URL}/api/database/tables/database/${databaseId}/`,
|
|
{ headers },
|
|
);
|
|
|
|
if (listResponse.ok()) {
|
|
const tables = (await listResponse.json()) as Array<{ id: number; name: string }>;
|
|
const existing = tables.find((t) => t.name === "E2E Table");
|
|
if (existing) {
|
|
// Fetch fields for the existing table.
|
|
return await resolveTableFields(apiContext, token, existing.id);
|
|
}
|
|
}
|
|
|
|
const createResponse = await apiContext.post(
|
|
`${BASEROW_URL}/api/database/tables/database/${databaseId}/`,
|
|
{
|
|
headers: { ...headers, "Content-Type": "application/json" },
|
|
data: {
|
|
name: "E2E Table",
|
|
// Seed with column definitions upfront so we have a single_select + date field.
|
|
data: [["Task", "Status", "Due Date"]],
|
|
first_row_is_header: true,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!createResponse.ok()) {
|
|
const body = await createResponse.text();
|
|
throw new Error(`Create table failed: ${createResponse.status()} — ${body}`);
|
|
}
|
|
|
|
const created = (await createResponse.json()) as { id: number };
|
|
const tableId = created.id;
|
|
|
|
// Add the single_select and date fields via field endpoints.
|
|
await addSingleSelectField(apiContext, token, tableId);
|
|
await addDateField(apiContext, token, tableId);
|
|
|
|
return await resolveTableFields(apiContext, token, tableId);
|
|
}
|
|
|
|
async function addSingleSelectField(
|
|
apiContext: APIRequestContext,
|
|
token: string,
|
|
tableId: number,
|
|
): Promise<void> {
|
|
const headers = { Authorization: `JWT ${token}`, "Content-Type": "application/json" };
|
|
|
|
await apiContext.post(`${BASEROW_URL}/api/database/fields/table/${tableId}/`, {
|
|
headers,
|
|
data: {
|
|
name: "Status",
|
|
type: "single_select",
|
|
select_options: [
|
|
{ value: "Todo", color: "blue" },
|
|
{ value: "In Progress", color: "yellow" },
|
|
{ value: "Done", color: "green" },
|
|
],
|
|
},
|
|
});
|
|
}
|
|
|
|
async function addDateField(
|
|
apiContext: APIRequestContext,
|
|
token: string,
|
|
tableId: number,
|
|
): Promise<void> {
|
|
const headers = { Authorization: `JWT ${token}`, "Content-Type": "application/json" };
|
|
|
|
await apiContext.post(`${BASEROW_URL}/api/database/fields/table/${tableId}/`, {
|
|
headers,
|
|
data: {
|
|
name: "Due Date",
|
|
type: "date",
|
|
date_format: "ISO",
|
|
},
|
|
});
|
|
}
|
|
|
|
async function resolveTableFields(
|
|
apiContext: APIRequestContext,
|
|
token: string,
|
|
tableId: number,
|
|
): Promise<{
|
|
tableId: number;
|
|
primaryFieldId: number;
|
|
primaryFieldName: string;
|
|
singleSelectFieldId: number;
|
|
singleSelectFieldName: string;
|
|
dateFieldId: number;
|
|
dateFieldName: string;
|
|
}> {
|
|
const headers = { Authorization: `JWT ${token}` };
|
|
const response = await apiContext.get(
|
|
`${BASEROW_URL}/api/database/fields/table/${tableId}/`,
|
|
{ headers },
|
|
);
|
|
|
|
if (!response.ok()) {
|
|
throw new Error(`List fields failed for table ${tableId}: ${response.status()}`);
|
|
}
|
|
|
|
const fields = (await response.json()) as Array<{
|
|
id: number;
|
|
name: string;
|
|
type: string;
|
|
primary?: boolean;
|
|
}>;
|
|
|
|
const primaryField =
|
|
fields.find((f) => f.primary) ?? fields[0];
|
|
const singleSelectField = fields.find((f) => f.type === "single_select");
|
|
const dateField = fields.find((f) => f.type === "date");
|
|
|
|
if (!primaryField || !singleSelectField || !dateField) {
|
|
throw new Error(
|
|
`Table ${tableId} missing required fields. Fields: ${JSON.stringify(fields.map((f) => ({ id: f.id, name: f.name, type: f.type })))}`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
tableId,
|
|
primaryFieldId: primaryField.id,
|
|
primaryFieldName: primaryField.name,
|
|
singleSelectFieldId: singleSelectField.id,
|
|
singleSelectFieldName: singleSelectField.name,
|
|
dateFieldId: dateField.id,
|
|
dateFieldName: dateField.name,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create grid, kanban, and calendar views for the table (or reuse existing).
|
|
*/
|
|
async function ensureViews(
|
|
apiContext: APIRequestContext,
|
|
token: string,
|
|
tableId: number,
|
|
): Promise<{ gridViewId: number; kanbanViewId: number; calendarViewId: number }> {
|
|
const headers = { Authorization: `JWT ${token}` };
|
|
|
|
const listResponse = await apiContext.get(
|
|
`${BASEROW_URL}/api/database/views/table/${tableId}/`,
|
|
{ headers },
|
|
);
|
|
|
|
type ViewShape = { id: number; name: string; type: string };
|
|
let views: ViewShape[] = [];
|
|
if (listResponse.ok()) {
|
|
views = (await listResponse.json()) as ViewShape[];
|
|
}
|
|
|
|
async function ensureView(
|
|
name: string,
|
|
type: string,
|
|
extraData?: Record<string, unknown>,
|
|
): Promise<number> {
|
|
const existing = views.find((v) => v.name === name && v.type === type);
|
|
if (existing) return existing.id;
|
|
|
|
const createResponse = await apiContext.post(
|
|
`${BASEROW_URL}/api/database/views/table/${tableId}/`,
|
|
{
|
|
headers: { ...headers, "Content-Type": "application/json" },
|
|
data: { name, type, ...extraData },
|
|
},
|
|
);
|
|
|
|
if (!createResponse.ok()) {
|
|
const body = await createResponse.text();
|
|
throw new Error(`Create ${type} view failed: ${createResponse.status()} — ${body}`);
|
|
}
|
|
|
|
const created = (await createResponse.json()) as { id: number };
|
|
return created.id;
|
|
}
|
|
|
|
const gridViewId = await ensureView("E2E Grid", "grid");
|
|
const kanbanViewId = await ensureView("E2E Kanban", "gallery"); // Baserow uses "gallery" for kanban-like
|
|
const calendarViewId = await ensureView("E2E Calendar", "calendar");
|
|
|
|
return { gridViewId, kanbanViewId, calendarViewId };
|
|
}
|
|
|
|
/**
|
|
* Seed rows into the table. Returns the created row IDs.
|
|
* Idempotent: if rows already exist (count >= 5), returns first 5 IDs.
|
|
*/
|
|
async function seedRows(
|
|
apiContext: APIRequestContext,
|
|
token: string,
|
|
tableId: number,
|
|
fields: {
|
|
primaryFieldName: string;
|
|
singleSelectFieldName: string;
|
|
dateFieldName: string;
|
|
},
|
|
): Promise<number[]> {
|
|
const headers = { Authorization: `JWT ${token}` };
|
|
|
|
// Check existing rows first.
|
|
const listResponse = await apiContext.get(
|
|
`${BASEROW_URL}/api/database/rows/table/${tableId}/?user_field_names=true`,
|
|
{ headers },
|
|
);
|
|
|
|
if (listResponse.ok()) {
|
|
const listData = (await listResponse.json()) as {
|
|
count: number;
|
|
results: Array<{ id: number }>;
|
|
};
|
|
if (listData.count >= 5) {
|
|
return listData.results.slice(0, 5).map((r) => r.id);
|
|
}
|
|
}
|
|
|
|
const now = new Date();
|
|
const rowsToCreate = [
|
|
{
|
|
[fields.primaryFieldName]: "Task Alpha",
|
|
[fields.singleSelectFieldName]: "Todo",
|
|
[fields.dateFieldName]: new Date(now.getTime() + 86_400_000)
|
|
.toISOString()
|
|
.slice(0, 10),
|
|
},
|
|
{
|
|
[fields.primaryFieldName]: "Task Beta",
|
|
[fields.singleSelectFieldName]: "In Progress",
|
|
[fields.dateFieldName]: new Date(now.getTime() + 2 * 86_400_000)
|
|
.toISOString()
|
|
.slice(0, 10),
|
|
},
|
|
{
|
|
[fields.primaryFieldName]: "Task Gamma",
|
|
[fields.singleSelectFieldName]: "Done",
|
|
[fields.dateFieldName]: new Date(now.getTime() + 3 * 86_400_000)
|
|
.toISOString()
|
|
.slice(0, 10),
|
|
},
|
|
{
|
|
[fields.primaryFieldName]: "Task Delta",
|
|
[fields.singleSelectFieldName]: "Todo",
|
|
[fields.dateFieldName]: new Date(now.getTime() + 4 * 86_400_000)
|
|
.toISOString()
|
|
.slice(0, 10),
|
|
},
|
|
{
|
|
[fields.primaryFieldName]: "Task Epsilon",
|
|
[fields.singleSelectFieldName]: "In Progress",
|
|
[fields.dateFieldName]: new Date(now.getTime() + 5 * 86_400_000)
|
|
.toISOString()
|
|
.slice(0, 10),
|
|
},
|
|
];
|
|
|
|
const rowIds: number[] = [];
|
|
|
|
for (const rowData of rowsToCreate) {
|
|
const createResponse = await apiContext.post(
|
|
`${BASEROW_URL}/api/database/rows/table/${tableId}/?user_field_names=true`,
|
|
{
|
|
headers: { ...headers, "Content-Type": "application/json" },
|
|
data: rowData,
|
|
},
|
|
);
|
|
|
|
if (!createResponse.ok()) {
|
|
const body = await createResponse.text();
|
|
throw new Error(`Create row failed: ${createResponse.status()} — ${body}`);
|
|
}
|
|
|
|
const created = (await createResponse.json()) as { id: number };
|
|
rowIds.push(created.id);
|
|
}
|
|
|
|
return rowIds;
|
|
}
|
|
|
|
/**
|
|
* Full pre-seed: workspace + database + table + views + rows.
|
|
* Returns a BaserowSeed descriptor that tests use to target specific entities.
|
|
*/
|
|
export async function seedBaserow(
|
|
apiContext: APIRequestContext,
|
|
): Promise<BaserowSeed> {
|
|
const token = await getBaserowToken(apiContext);
|
|
const workspaceId = await ensureWorkspace(apiContext, token);
|
|
const databaseId = await ensureDatabase(apiContext, token, workspaceId);
|
|
|
|
const {
|
|
tableId,
|
|
primaryFieldName,
|
|
singleSelectFieldName,
|
|
dateFieldName,
|
|
} = await ensureTable(apiContext, token, databaseId);
|
|
|
|
const { gridViewId, kanbanViewId, calendarViewId } = await ensureViews(
|
|
apiContext,
|
|
token,
|
|
tableId,
|
|
);
|
|
|
|
const rowIds = await seedRows(apiContext, token, tableId, {
|
|
primaryFieldName,
|
|
singleSelectFieldName,
|
|
dateFieldName,
|
|
});
|
|
|
|
return {
|
|
workspaceId,
|
|
databaseId,
|
|
tableId,
|
|
gridViewId,
|
|
kanbanViewId,
|
|
calendarViewId,
|
|
token,
|
|
rowIds,
|
|
singleSelectFieldName,
|
|
dateFieldName,
|
|
primaryFieldName,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update a single row directly via Baserow API (used in SSE tests).
|
|
*/
|
|
export async function updateRowViaBaserowApi(
|
|
apiContext: APIRequestContext,
|
|
token: string,
|
|
tableId: number,
|
|
rowId: number,
|
|
data: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const response = await apiContext.patch(
|
|
`${BASEROW_URL}/api/database/rows/table/${tableId}/${rowId}/?user_field_names=true`,
|
|
{
|
|
headers: {
|
|
Authorization: `JWT ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
data,
|
|
},
|
|
);
|
|
|
|
if (!response.ok()) {
|
|
const body = await response.text();
|
|
throw new Error(
|
|
`Baserow row update failed (table ${tableId}, row ${rowId}): ${response.status()} — ${body}`,
|
|
);
|
|
}
|
|
}
|