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
94 changes: 83 additions & 11 deletions tests/acceptance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,17 @@ with an opaque error.
Projects are routed by tag, the way core does it. A spec opts into a project by carrying its tag in
the title.

| Project | Tag | Notes |
| ----------------- | ------------------ | -------------------------------------------------------------------- |
| `Signer` | `@Signer` | RFC 9421 signer proofs. No browser. Every UCP project depends on it. |
| `Setup` | `@Setup` | The known-blockers guard and the bootstrap check. |
| `UcpProtocol` | `@UcpProtocol` | UCP transport journeys. |
| `UcpContent` | `@UcpContent` | Product feed, tracking, discovery files. |
| `UcpEmbedded` | `@UcpEmbedded` | Embedded transport. |
| `UcpAdmin` | `@UcpAdmin` | Administration UI. |
| `UcpAcl` | `@UcpAcl` | ACL matrix. |
| `UcpSerial` | `@UcpSerial` | Runs with `workers: 1` for specs that cannot be parallelised. |
| `UcpKnownBlocked` | `@UcpKnownBlocked` | **Non-gating.** See below. |
| Project | Tag | Notes |
| ----------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `Signer` | `@Signer` | RFC 9421 signer proofs. No browser. Every UCP project depends on it. |
| `Setup` | `@Setup` | The known-blockers guard, the bootstrap check and the fixture proofs. `@UcpConsole` marks the one spec that needs `bin/console`. |
| `UcpProtocol` | `@UcpProtocol` | UCP transport journeys. |
| `UcpContent` | `@UcpContent` | Product feed, tracking, discovery files. |
| `UcpEmbedded` | `@UcpEmbedded` | Embedded transport. |
| `UcpAdmin` | `@UcpAdmin` | Administration UI. |
| `UcpAcl` | `@UcpAcl` | ACL matrix. |
| `UcpSerial` | `@UcpSerial` | Runs with `workers: 1` for specs that cannot be parallelised. |
| `UcpKnownBlocked` | `@UcpKnownBlocked` | **Non-gating.** See below. |

Most projects match no specs yet; their issues add them. A project with no matching spec reports
zero tests and passes.
Expand Down Expand Up @@ -101,6 +101,70 @@ import { expect, test } from "@fixtures/AcceptanceTest";
`fixtures/AcceptanceTest.ts` calls `mergeTests(ShopwareTestSuite, ...)` and re-exports the package,
so a spec never has to know which file a fixture came from.

## Fixtures

Every Playwright worker owns its test data. The ATS `DefaultSalesChannel` fixture gives each worker
a storefront-type sales channel on the path-prefixed domain `${APP_URL}test-<uuid>/`, so the
Administration shows the Agentic Commerce tab for it. That domain does not give the worker a profile
of its own yet: `/.well-known/ucp` under a prefixed domain resolves another channel (known blocker
D8), so specs read a channel's profile through the Admin API preview until that is fixed. The plugin
fixtures build on the worker channel.

**`TestDataService`** (test scope) is `UcpTestDataService`, the ATS `TestDataService` plus UCP:
`createStorefrontSalesChannel()`, `createHeadlessSalesChannel()`, `createFeedSalesChannel()`,
`activateUcp()`, `saveUcpConfig()`, `getUcpConfig()`, `getProfilePreview()` and
`listUcpSalesChannels()`. `activateUcp()` enables every capability and the REST transport and
allowlists the agent profile host on all three per-channel lists, because the SDK falls back to the
shop's own host for an empty list and would refuse a `localhost` profile. Its cleanup runs before
the ATS registry's. It restores the UCP config each surviving channel had before the first write,
deletes the signing keys of the channels it created and activated when `UcpConsole` reaches
`bin/console`, and deletes the channels it created. Every step runs even when an earlier one fails,
and the failures are reported together.

**`UcpAgentProfileHost`** (worker scope) has `publish()`, which generates an ES256 key pair and
writes the agent's profile, public key included, to
`<SHOPWARE_DIR>/public/ucp-acceptance-agents/<kid>.json`. The profile carries every shopping
capability the UCP specification defines at the protocol version, so the SDK has something to
negotiate, and `publish({ capabilities })` replaces that set for negative specs. The shop fetches
the profile from `UCP_AGENT_PROFILE_BASE_URL` (default `http://localhost:8000`), the web container's
own document root, the only plain-http host the SDK admits and only in development mode. The fixture
verifies the file is served through `APP_URL` and throws otherwise. It never falls back to the
shop's own profile. A worker removes its own files at teardown.

**`UcpAclUsers`** (test scope) has `as('ucp.viewer' | 'ucp.editor' | 'ucp.key_rotator')`, which
creates an ACL role and a non-admin user, logs into the Administration in a separate page context
and returns it. The privilege sets are read from
`src/Resources/app/administration/src/extension/sw-sales-channel/acl/index.js`, with core's
`sales_channel.viewer` set added so the user can reach the sales channel at all.

**`UcpConsole`** (worker scope) runs `ucp:signing-keys:{generate,list,show-public,retire,delete}`,
the only signing-key management surface, through the lane's `bin/console`. Nothing else passes its
allow-list. Before the first command it probes the real prefix once. When that probe fails, the key
cleanup is skipped with a warning naming the sales channels whose keys stay behind, and the
`@UcpConsole` spec fails.

The lane has to run with `SWAG_AGENTIC_COMMERCE_UCP_PROFILE_FETCHING_DEVELOPMENT_MODE=1` for the
shop to fetch a test agent's profile from `localhost` over plain http.

### What the fixtures need from their host

Beyond `APP_URL`, two fixtures touch the Shopware project directly. Each resolves what it needs from
an environment variable when it is first used, and fails loudly when it cannot:

| Variable | Default | Needed by |
| -------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `SHOPWARE_DIR` | the nearest ancestor of this directory with a `bin/console` | `UcpAgentProfileHost` writes into its `public/`; `UcpConsole` runs there |
| `PLUGIN_DIR` | the checkout this suite sits in, else the `custom/plugins` directory that holds `shopware/agentic-commerce` | reading `UcpProtocol::VERSION` and the Administration ACL file |
| `UCP_CONSOLE` | `docker compose exec -T web php bin/console` | `UcpConsole` |

A runner that reaches Shopware only over HTTP can run every spec that uses neither fixture. The
profile host and the console need the project mounted.

`UCP_CONSOLE` is executed directly, not through a shell, so it cannot see shell aliases. On a
machine where `docker` is only an alias for `podman`, the default prefix is not found and the
signing keys of created channels are left behind. Set `UCP_CONSOLE` to a command that reaches the
web container without an alias.

## Environment

Read by the ATS itself: `APP_URL`, `ADMIN_API_URL`, `ADMIN_URL`, `SHOPWARE_ACCESS_KEY_ID`,
Expand All @@ -110,6 +174,9 @@ Read by the ATS itself: `APP_URL`, `ADMIN_API_URL`, `ADMIN_URL`, `SHOPWARE_ACCES

Read by this config: `SHOPWARE_PLAYWRIGHT_IGNORE_HTTPS_ERRORS`, `DATABASE_URL`, `CI`.

Read by the plugin fixtures: `SHOPWARE_DIR`, `PLUGIN_DIR`, `UCP_AGENT_PROFILE_BASE_URL`,
`UCP_CONSOLE`, `UCP_CONSOLE_TIMEOUT_MS`, `ATS_SKIP_CLEANUP`.

> `DATABASE_URL` is parsed into `ATS_DATABASE_USERNAME`/`_PASSWORD`/`_HOST`/`_NAME` for parity with
> core's configuration, but **nothing consumes those variables today**. ATS 12.20.0 ships no
> database driver and reaches Shopware only over the Admin API, the Store API and Mailpit. Do not go
Expand All @@ -125,6 +192,11 @@ reuse the same records rather than accumulating new ones. Expect one `<id> accep
channel per parallel worker to remain in the shop after a run. Changing `ATS_ID_SEED` produces a new
set.

Activating UCP on a channel auto-provisions a signing key in the SDK's key store, and on a Shopware
that ships the sales-channel file subsystem it also enables the agentic files for that channel. The
worker channel keeps both. Signing keys of the channels the suite created are removed only when
`UcpConsole` can reach `bin/console`; otherwise they outlive their channel.

## Notes

- `npm install` warns that `skia-canvas` has an install script npm 11 does not run by default. It is
Expand Down
23 changes: 22 additions & 1 deletion tests/acceptance/fixtures/AcceptanceTest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
import { test as ShopwareTestSuite, mergeTests } from '@shopware-ag/acceptance-test-suite';
import type { FixtureTypes as BaseTypes } from '@shopware-ag/acceptance-test-suite';
import { test as ucpConsole } from './UcpConsole';
import { test as ucpTestData } from './UcpTestData';
import { test as ucpAgentProfileHost } from './UcpAgentProfileHost';
import { test as ucpAclUsers } from './UcpAclUsers';
import type { UcpConsoleTypes } from './UcpConsole';
import type { UcpTestDataFixtureTypes } from './UcpTestData';
import type { UcpAgentProfileHostTypes } from './UcpAgentProfileHost';
import type { UcpAclUsersTypes } from './UcpAclUsers';

export * from '@shopware-ag/acceptance-test-suite';

export const test = mergeTests(ShopwareTestSuite);
export type FixtureTypes = Omit<BaseTypes, 'TestDataService'>
& UcpTestDataFixtureTypes
& UcpConsoleTypes
& UcpAgentProfileHostTypes
& UcpAclUsersTypes;

export const test = mergeTests(
ShopwareTestSuite,
ucpConsole,
ucpTestData,
ucpAgentProfileHost,
ucpAclUsers,
);
113 changes: 113 additions & 0 deletions tests/acceptance/fixtures/UcpAclUsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { test as base } from '@playwright/test';
import type { Page } from '@playwright/test';
import { createNewAdminPageContext, loginToAdministration } from '@shopware-ag/acceptance-test-suite';
import type { FixtureTypes, User } from '@shopware-ag/acceptance-test-suite';
import { readAdminPrivilegeMapping, resolveRole } from '@services/pluginSource';
import type { UcpTestDataFixtureTypes } from './UcpTestData';

export const UCP_ROLES = ['ucp.viewer', 'ucp.editor', 'ucp.key_rotator'] as const;
export type UcpRole = typeof UCP_ROLES[number];

/**
* Core's `sales_channel.viewer` privileges (`sw-sales-channel/acl/index.js` on trunk), flattened.
* Without them a user cannot open the sales channel the Agentic Commerce tab sits on.
*/
const SALES_CHANNEL_VIEWER_PRIVILEGES = [
'sales_channel:read',
'sales_channel_type:read',
'payment_method:read',
'shipping_method:read',
'country:read',
'currency:read',
'sales_channel_domain:read',
'sales_channel_file:read',
'snippet_set:read',
'sales_channel_analytics:read',
'product_export:read',
'theme:read',
'custom_field_set:read',
'custom_field:read',
'custom_field_set_relation:read',
'category:read',
'customer_group:read',
'media:read',
'media_folder:read',
'media_default_folder:read',
'product:read',
'product_stream:read',
'product_visibility:read',
'property_group:read',
'property_group_option:read',
'user_config:read',
'user_config:create',
'user_config:update',
'system_config:read',
'sales_channel_tracking_order:read',
'sales_channel_tracking_customer:read',
'order:read',
'order_transaction:read',
'state_machine_state:read',
];

export interface UcpAclUser {
role: UcpRole
user: User
privileges: string[]
page: Page
}

export interface UcpAclUsers {
/** One Administration user per UCP role, created and logged in on first use. */
as(role: UcpRole): Promise<UcpAclUser>
}

export interface UcpAclUsersTypes {
UcpAclUsers: UcpAclUsers
}

export const test = base.extend<FixtureTypes & UcpTestDataFixtureTypes & UcpAclUsersTypes>({
UcpAclUsers: async ({ TestDataService, AdminApiContext, SalesChannelBaseConfig, browser }, use) => {
const mapping = await readAdminPrivilegeMapping();
const users = new Map<UcpRole, Promise<UcpAclUser>>();

const create = async (role: UcpRole): Promise<UcpAclUser> => {
const resolved = resolveRole(mapping, role);
const privileges = [...new Set([
...(TestDataService.getBasicAclRoleStruct().privileges ?? []),
...SALES_CHANNEL_VIEWER_PRIVILEGES,
'sales_channel.viewer',
...resolved.keys,
...resolved.privileges,
])];

const aclRole = await TestDataService.createAclRole({ name: `${TestDataService.namePrefix}${role}-${aclRoleSuffix()}`, privileges });
const user = await TestDataService.createUserRetryingTokenConflicts({ admin: false });
await TestDataService.assignAclRoleUser(aclRole.id, user.id);

const page = await loginToAdministration(await createNewAdminPageContext(browser, SalesChannelBaseConfig), user, AdminApiContext);

return { role, user, privileges, page };
};

await use({
as: (role) => {
let pending = users.get(role);
if (pending === undefined) {
pending = create(role);
users.set(role, pending);
}

return pending;
},
});

for (const pending of users.values()) {
const { page } = await pending.catch(() => ({ page: null }));
await page?.context().close();
}
},
});

function aclRoleSuffix(): string {
return Math.random().toString(36).slice(2, 8);
}
Loading
Loading