Files
TX/artifacts/tx-os/tests/admin-app-image-external-embedded.spec.mjs
T
riyadhafraa e7948012f4 Task #517: App image upload, external links, built-in route lock
Admin Add/Edit App now supports:
- Custom image upload (or fall back to Lucide icon) via the existing
  ServiceImageUploader; rendered on the home launcher when set.
- Open mode picker: internal (default), external_tab (window.open),
  external_iframe (renders inside /embedded/:id). External URL input
  shown conditionally and required when an external mode is chosen.
  Internal route field is hidden entirely when an external mode is
  selected, and locked (readOnly + lock hint) when editing a built-in
  app whose path is hardcoded in the SPA.

Backend:
- apps schema gains image_url, external_url, open_mode (default
  'internal'); drizzle-kit push applied.
- New lib/db/src/built-in-apps.ts exports BUILTIN_APP_SLUGS +
  isBuiltinAppSlug. Exposed via subpath export
  `@workspace/db/built-in-apps`; the file has zero imports so the
  browser bundle can use it without pulling in `pg`. tx-os now imports
  it directly — duplicate FE constant removed.
- Built-in slug list: services, notifications, admin, notes,
  my-orders, orders-incoming, executive-meetings (everything with a
  hardcoded <Route> in artifacts/tx-os/src/App.tsx). calendar /
  documents are seeded but admin-defined and remain editable.
- PATCH /apps/:id rejects route changes whose previous slug is
  built-in with 400 + code='builtin_route_locked'. Same-route no-op
  is allowed; non-route updates on built-ins still work.

Other:
- New SPA route /embedded/:id and embedded-app page (iframe host with
  back + open-in-new-tab + error/not-embeddable states).
- OpenAPI App / CreateAppBody / UpdateAppBody extended; codegen ran.
- en/ar locales: admin.appImage, appExternalUrl, appOpenMode.*,
  builtinPathLocked, embeddedFrame.*.
- scripts/src/seed.ts: drift guard throws if a seeded built-in slug
  uses a route that does not match the hardcoded SPA route.

Tests:
- New API test apps-builtin-route-lock.test.mjs (4/4 pass): reject
  built-in route change, allow non-route built-in updates, allow
  non-builtin route changes, allow built-in same-route no-op.
- New Playwright E2E admin-app-image-external-embedded.spec.mjs
  (passes): launcher renders custom image_url, external_tab opens
  external URL via window.open, external_iframe navigates to
  /embedded/:id and renders <iframe src=externalUrl>.

Out of scope (pre-existing, not introduced here):
- 3 failing tests in executive-meetings-* and tsc errors in
  api-server/src/routes/executive-meetings.ts.
2026-05-12 12:31:43 +00:00

154 lines
5.6 KiB
JavaScript

// Task #517 E2E:
// 1) An app with an `image_url` renders that image (not a Lucide icon)
// on the launcher tile.
// 2) An app with `open_mode='external_tab'` opens its `external_url` in
// a new browser tab when the launcher tile is clicked (we intercept
// window.open).
// 3) An app with `open_mode='external_iframe'` navigates the SPA to
// `/embedded/:id`, which renders an <iframe src=externalUrl>.
// All three apps are inserted directly into the DB (via pg) and cleaned
// up in afterAll, so this test is self-contained.
import { test, expect } from "@playwright/test";
import pg from "pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run this test");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
// External https URL — resolveServiceImageUrl passes http(s) through
// untouched, so the <img src=...> on the tile equals this exact value.
// We don't actually need the network fetch to succeed; we only assert
// that an <img> with this src is rendered (i.e. a custom image is
// being used instead of a Lucide icon).
const CUSTOM_IMAGE_URL = "https://example.invalid/test-tile-image.png";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdAppIds = [];
const createdUserIds = [];
let username;
function uniqueSuffix() {
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
}
async function insertApp({ slug, nameEn, openMode, imageUrl, externalUrl, route }) {
const { rows } = await pool.query(
`INSERT INTO apps
(slug, name_ar, name_en, icon_name, route, color, sort_order,
is_active, image_url, external_url, open_mode)
VALUES ($1, $2, $3, 'Grid2X2', $4, '#6366f1', 9999,
TRUE, $5, $6, $7)
RETURNING id`,
[slug, nameEn, nameEn, route, imageUrl, externalUrl, openMode],
);
const id = rows[0].id;
createdAppIds.push(id);
return id;
}
test.afterAll(async () => {
if (createdAppIds.length > 0) {
await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [createdAppIds]);
}
if (createdUserIds.length > 0) {
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [createdUserIds]);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [createdUserIds]);
}
await pool.end();
});
test.beforeAll(async () => {
username = `e2e_app_open_${uniqueSuffix()}`;
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, $1, 'en', TRUE) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
);
createdUserIds.push(rows[0].id);
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'user'`,
[rows[0].id],
);
});
async function loginUI(page) {
await page.goto("/login");
await page.locator("#username").fill(username);
await page.locator("#password").fill(TEST_PASSWORD);
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15_000 }),
page.locator('form button[type="submit"]').click(),
]);
}
test("launcher renders custom image_url and external open modes work", async ({ page, context }) => {
// Insert three apps with distinct, identifiable names so we can find
// their tiles on the launcher.
const stamp = uniqueSuffix();
const imageAppName = `ImageApp_${stamp}`;
const tabAppName = `TabApp_${stamp}`;
const iframeAppName = `IframeApp_${stamp}`;
await insertApp({
slug: `image_app_${stamp}`,
nameEn: imageAppName,
openMode: "internal",
imageUrl: CUSTOM_IMAGE_URL,
externalUrl: null,
route: `/img-${stamp}`,
});
await insertApp({
slug: `tab_app_${stamp}`,
nameEn: tabAppName,
openMode: "external_tab",
imageUrl: null,
externalUrl: "https://example.com/tab-target",
route: "/unused-tab",
});
const iframeAppId = await insertApp({
slug: `iframe_app_${stamp}`,
nameEn: iframeAppName,
openMode: "external_iframe",
imageUrl: null,
externalUrl: "https://example.com/iframe-target",
route: "/unused-iframe",
});
await loginUI(page);
// 1) Custom image rendering on launcher tile.
const imageTile = page.getByText(imageAppName, { exact: true }).first();
await expect(imageTile).toBeVisible({ timeout: 15_000 });
// Tile should contain an <img> whose src is our custom image URL —
// proves the launcher uses the uploaded image instead of a Lucide icon.
const tileImg = page.locator(`img[src="${CUSTOM_IMAGE_URL}"]`).first();
await expect(tileImg).toHaveCount(1);
// 2) external_tab opens external URL in a new tab.
// window.open is what the launcher uses for external_tab. Stub it
// to record the URL instead of actually navigating.
await page.evaluate(() => {
window.__opened = [];
window.open = (url) => {
window.__opened.push(String(url));
return null;
};
});
await page.getByText(tabAppName, { exact: true }).first().click();
const openedUrls = await page.evaluate(() => window.__opened ?? []);
expect(openedUrls).toContain("https://example.com/tab-target");
// 3) external_iframe → SPA navigates to /embedded/:id and renders iframe.
await page.getByText(iframeAppName, { exact: true }).first().click();
await page.waitForURL(new RegExp(`/embedded/${iframeAppId}(?:$|[?/])`));
const iframeEl = page.locator(`iframe[src="https://example.com/iframe-target"]`);
await expect(iframeEl).toBeVisible({ timeout: 10_000 });
});