Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
254 changes: 254 additions & 0 deletions cms-ui/apps/editor-ui/e2e/copilot.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { Page as CmsPage } from '@gentics/cms-models';
import {
EntityImporter,
loginWithForm,
navigateToApp,
NODE_MINIMAL,
PAGE_ONE,
TestSize,
} from '@gentics/e2e-utils';
import { expect, Locator, Page, test } from '@playwright/test';
import { AUTH } from './common';
import { openPageForEditing, selectNode } from './helpers';

/*
* The Content Copilot is feature-flagged via a YAML file the customer drops
* into `{ui-conf}/config/copilot.yml`. The CI image obviously does not ship
* one, so every test here intercepts the request and serves a tailored YAML
* (or a 404 when the test wants the disabled state).
*
* Crucial: the route MUST be installed BEFORE `navigateToApp`, because the
* fetch is kicked off by `AppComponent.ngOnInit()` right after bootstrap.
* That is why the navigation/login/select-node trio is wrapped in
* `openEditorWithCopilot()` rather than living in a generic `beforeEach`
* the way other suites (e.g. page-editing.spec.ts) handle it.
*/

const COPILOT_CONFIG_URL_PATTERN = /\/ui-conf\/config\/copilot\.yml/;

const YAML_DISABLED = 'enabled: false\nactions: []\n';
const YAML_ENABLED_NO_ACTIONS = 'enabled: true\nactions: []\n';
const YAML_ENABLED_WITH_ACTIONS = `enabled: true
actions:
- id: summarize
label: Zusammenfassen
icon: summarize
description: Eine kurze Zusammenfassung der Seite erstellen
- id: rewrite-tone
label: Tonalität anpassen
icon: edit_note
`;

/** Stubs the customer copilot.yml endpoint for the lifetime of the page. */
async function stubCopilotConfig(page: Page, yaml: string | null): Promise<void> {
await page.route(COPILOT_CONFIG_URL_PATTERN, (route) => {
if (yaml === null) {
return route.fulfill({ status: 404, body: 'Not Found' });
}
return route.fulfill({
status: 200,
contentType: 'text/yaml',
body: yaml,
});
});
}

/** Navigation + login + node selection — all the steps that other suites do
* in `beforeEach`, but invoked AFTER the YAML stub so the Copilot bootstrap
* fetch sees the per-test response. */
async function openEditorWithCopilot(
page: Page,
importer: EntityImporter,
yaml: string | null,
): Promise<void> {
await stubCopilotConfig(page, yaml);
await navigateToApp(page);
await loginWithForm(page, AUTH.admin);
await selectNode(page, importer.get(NODE_MINIMAL).id);
}

function copilotButton(page: Page): Locator {
return page.locator('content-frame gtx-editor-toolbar [data-action="copilot"]');
}

function copilotSidebar(page: Page): Locator {
return page.locator('gtx-copilot-sidebar .copilot-sidebar');
}

function copilotEmptyState(page: Page): Locator {
return copilotSidebar(page).locator('.copilot-empty-state');
}

function copilotActionItem(page: Page, id: string): Locator {
return copilotSidebar(page).locator(`.copilot-action-item[data-id="${id}"]`);
}

test.describe('Content Copilot', () => {

const IMPORTER = new EntityImporter();

test.beforeAll(async ({ request }) => {
await test.step('Client Setup', async () => {
IMPORTER.setApiContext(request);
await IMPORTER.clearClient();
});

await test.step('Test Bootstrapping', async () => {
await IMPORTER.cleanupTest();
await IMPORTER.bootstrapSuite(TestSize.MINIMAL);
});
});

test.beforeEach(async ({ request, context }) => {
await test.step('Client Setup', async () => {
IMPORTER.setApiContext(request);
await context.clearCookies();
await IMPORTER.clearClient();
});

await test.step('Common Test Setup', async () => {
await IMPORTER.cleanupTest();
await IMPORTER.setupTest(TestSize.MINIMAL);
});
});

test.describe('Toolbar button visibility', () => {

test('button stays hidden when copilot.yml is missing (404)', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, null);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

// Wait for the toolbar to be present so we don't race against the
// initial render — toBeHidden / toHaveCount(0) are the defining
// assertions.
await expect(page.locator('content-frame gtx-editor-toolbar')).toBeVisible();
await expect(copilotButton(page)).toHaveCount(0);
});

test('button stays hidden when copilot.yml has enabled: false', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, YAML_DISABLED);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await expect(page.locator('content-frame gtx-editor-toolbar')).toBeVisible();
await expect(copilotButton(page)).toHaveCount(0);
});

test('button appears when copilot.yml has enabled: true and a page is in edit mode', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, YAML_ENABLED_NO_ACTIONS);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await expect(copilotButton(page)).toBeVisible();
});

test('button stays hidden as long as no page is opened in edit mode', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, YAML_ENABLED_NO_ACTIONS);
// Deliberately NOT opening a page — we should still be on the
// folder list, where there is no editor toolbar at all.
await expect(copilotButton(page)).toHaveCount(0);
});

test('button stays hidden when an invalid copilot.yml is served', async ({ page }) => {
// Missing required `id` on the action — the parser falls back to
// the disabled default, so the button must NOT appear.
const invalid = 'enabled: true\nactions:\n - label: incomplete\n';
await openEditorWithCopilot(page, IMPORTER, invalid);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await expect(page.locator('content-frame gtx-editor-toolbar')).toBeVisible();
await expect(copilotButton(page)).toHaveCount(0);
});
});

test.describe('Sidebar interaction', () => {

test.beforeEach(async ({ page }) => {
// Default scenario for every interaction test: feature enabled,
// no actions configured. Tests that need actions install their
// own stub before this navigation by overriding via the helper.
});

test('clicking the toolbar button opens the sidebar', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, YAML_ENABLED_NO_ACTIONS);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

// Closed by default — verifying the precondition guards us against
// a bug where the sidebar mounts in the open state.
await expect(copilotSidebar(page)).not.toHaveClass(/is-open/);

await copilotButton(page).click();

await expect(copilotSidebar(page)).toHaveClass(/is-open/);
});

test('clicking the close icon collapses the sidebar again', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, YAML_ENABLED_NO_ACTIONS);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await copilotButton(page).click();
await expect(copilotSidebar(page)).toHaveClass(/is-open/);

await copilotSidebar(page).locator('[data-action="copilot-close"]').click();

await expect(copilotSidebar(page)).not.toHaveClass(/is-open/);
});

test('clicking the toolbar button a second time toggles the sidebar closed', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, YAML_ENABLED_NO_ACTIONS);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await copilotButton(page).click();
await expect(copilotSidebar(page)).toHaveClass(/is-open/);

await copilotButton(page).click();

await expect(copilotSidebar(page)).not.toHaveClass(/is-open/);
});
});

test.describe('Sidebar contents', () => {

test('shows the empty-state when actions: []', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, YAML_ENABLED_NO_ACTIONS);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await copilotButton(page).click();

await expect(copilotEmptyState(page)).toBeVisible();
await expect(copilotSidebar(page).locator('.copilot-action-item')).toHaveCount(0);
});

test('renders one card per configured action with id, label, icon, description', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, YAML_ENABLED_WITH_ACTIONS);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await copilotButton(page).click();

// Empty state must NOT be on the page once we have actions.
await expect(copilotEmptyState(page)).toHaveCount(0);

const items = copilotSidebar(page).locator('.copilot-action-item');
await expect(items).toHaveCount(2);

const summarize = copilotActionItem(page, 'summarize');
await expect(summarize).toBeVisible();
await expect(summarize.locator('.copilot-action-label')).toHaveText('Zusammenfassen');
await expect(summarize.locator('.copilot-action-description'))
.toHaveText('Eine kurze Zusammenfassung der Seite erstellen');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking for translatable text values is not a good way to test.
While the settings are stubed and therefore the values are known, these texts can't be directly used due to changes for label -> labelI18n and description -> descriptionI18n.

We'd first need to ensure a specific UI language (as it's initially determined by the browser/system language), and therefore translation texts may differ.


const rewrite = copilotActionItem(page, 'rewrite-tone');
await expect(rewrite).toBeVisible();
await expect(rewrite.locator('.copilot-action-label')).toHaveText('Tonalität anpassen');
});

test('chat input is rendered but disabled in this UI iteration', async ({ page }) => {
await openEditorWithCopilot(page, IMPORTER, YAML_ENABLED_NO_ACTIONS);
await openPageForEditing(page, IMPORTER.get(PAGE_ONE) as CmsPage);

await copilotButton(page).click();

const textarea = copilotSidebar(page).locator('.copilot-chat-textarea');
await expect(textarea).toBeVisible();
await expect(textarea).toBeDisabled();
});
});
});
10 changes: 10 additions & 0 deletions cms-ui/apps/editor-ui/public/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@
"link_checker_no_content": "Keine defekten Links vorhanden",
"constructs_empty": "Keine TagTypen einsetzbar/verfügbar"
},
"copilot": {
"button_label": "Copilot",
"button_tooltip": "Content Copilot öffnen",
"sidebar_title": "Content Copilot",
"close_label": "Copilot schließen",
"send_label": "Senden",
"input_placeholder": "Frage stellen oder Anweisung geben …",
"preview_hint": "Vorschau – Aktionen werden in Kürze freigeschaltet.",
"empty_state_message": "Keine Aktionen konfiguriert. Aktionen können über die Datei „copilot.yml“ in der UI-Konfiguration hinzugefügt werden."
},
"editor": {
"alert_center_title": "Warnungszentrale",
"alert_notification_singular_label": "Warnungszentrale: {{ count }} ungelöstes Problem wurde gefunden.",
Expand Down
10 changes: 10 additions & 0 deletions cms-ui/apps/editor-ui/public/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@
"link_checker_no_content": "No broken Links detected",
"constructs_empty": "No Constructs insertable/available"
},
"copilot": {
"button_label": "Copilot",
"button_tooltip": "Open Content Copilot",
"sidebar_title": "Content Copilot",
"close_label": "Close Copilot",
"send_label": "Send",
"input_placeholder": "Ask a question or give an instruction …",
"preview_hint": "Preview – actions will be enabled shortly.",
"empty_state_message": "No actions configured. Actions can be added via the \"copilot.yml\" file in the UI configuration."
},
"editor": {
"alert_center_title": "Alert Center",
"alert_notification_singular_label": "Alert Center: {{ count }} unresolved problem was found",
Expand Down
8 changes: 8 additions & 0 deletions cms-ui/apps/editor-ui/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { NavigationService } from './core/providers/navigation/navigation.servic
import { PermissionService } from './core/providers/permissions/permission.service';
import { UserSettingsService } from './core/providers/user-settings/user-settings.service';
import { UsersnapService } from './core/providers/usersnap/usersnap.service';
import { CopilotConfigService } from './copilot';
import { EmbeddedToolsService } from './embedded-tools/providers/embedded-tools/embedded-tools.service';
import { ChipSearchBarConfigService } from './shared/providers/chip-search-bar-config/chip-search-bar-config.service';
import { UIOverridesService } from './shared/providers/ui-overrides/ui-overrides.service';
Expand Down Expand Up @@ -148,6 +149,7 @@ export class AppComponent implements OnInit {
private router: Router,
private uiActions: UIActionsService,
private uiOverrides: UIOverridesService,
private copilotConfig: CopilotConfigService,
private userSettings: UserSettingsService,
private contentRepositoryActions: ContentRepositoryActionsService,
private usersnapService: UsersnapService,
Expand All @@ -172,6 +174,12 @@ export class AppComponent implements OnInit {
// Load customer-specific UI overrides
this.uiOverrides.loadCustomerConfiguration();

// Load Content Copilot configuration. Same lifecycle as the
// UI-overrides above — must run at app bootstrap (not from a
// lazy-loaded module) so the toolbar button is correctly
// gated by the customer's `copilot.yml` from the first paint.
this.copilotConfig.load();

this.maintenanceMode.refreshPeriodically(30000);
this.maintenanceMode.refreshOnLogout();
this.maintenanceMode.validateSessionWhenActivated();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,11 @@
<div class="hidden-file-picker" #filePickerWrapper>
<gtx-file-picker></gtx-file-picker>
</div>

<!--
Content Copilot drawer. Always rendered so the slide-in/out
transition has a stable host node; visibility is controlled by
CopilotStateService inside the component itself.
-->
<gtx-copilot-sidebar></gtx-copilot-sidebar>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,23 @@
</gtx-button>
}

@if (buttons.copilot) {
<gtx-button
class="primary-action copilot-button"
size="small"
flat
type="secondary"
data-action="copilot"
[class.is-active]="copilotOpen"
[attr.data-active]="copilotOpen"
[title]="'copilot.button_tooltip' | gtxI18n"
(click)="toggleCopilot()"
>
<icon left>auto_awesome</icon>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no icon named auto_awesome in the material symbols font.

<span class="copilot-button-label">{{ 'copilot.button_label' | gtxI18n }}</span>
</gtx-button>
}

@if (showSave) {
<gtx-split-button
class="primary-action save-button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,38 @@
margin-left: 0.25rem;
}

// Content Copilot toggle. Visually a secondary action like the focus
// / context-menu buttons, but with a subtle primary-coloured outline so
// it reads as the entry point into a feature that lives outside the
// regular save / publish flow. The extra right margin separates it
// visually from the Save group, signalling that it belongs to a
// different feature family.
.copilot-button {
margin-left: 0.5rem;
margin-right: 0.75rem;

gtx-button {
.copilot-button-label {
display: inline-flex;
align-items: center;
line-height: 1;
margin: 0 0.25rem;
font-weight: 500;
}
}

// Active = sidebar is open. Mirrors the highlight pattern used by
// the focus-mode button and existing dropdown triggers in this
// toolbar so the visual language stays consistent.
&.is-active {
gtx-button .button-event-wrapper button,
gtx-button button {
background-color: rgba($gtx-color-primary, 0.1);
color: $gtx-color-primary;
}
}
}

.page-editor-tabs {
flex: 1 1 auto;
align-self: center;
Expand Down
Loading