Skip to content
Open
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,19 @@ CI=true VITE_TEST_PORT=5176 bunx playwright test --config e2e/playwright.config.
## Gotchas

- **Dashboard stale build**: `packages/dev-server/dist/` accumulates stale Vite build artifacts across branch switches. Vite doesn't clean old hashed files, so old chunks can interfere (e.g. overwriting `window.schemaInfo`). Always `rm -rf packages/dev-server/dist` before rebuilding. Build with `bunx vite build --base /dashboard/ --outDir ../dev-server/dist` from `packages/dashboard/`. Also check no stale Vite dev server is running on port 5173 — `DashboardPlugin` auto-proxies to it instead of serving static files.
- **Don't take port 3010**: this repo's `dev-config.ts` uses `API_PORT = 3010` in the user's local working copy. Killing or starting another process on 3010 stomps the long-running dev backend. For ad-hoc test setups use a different port or rely on playwright's e2e env on 3050.
- **Branch hygiene**: never run `git checkout HEAD -- file` on a branch that has uncommitted feature work without committing first — on a fresh branch HEAD == master, so the working copy is wiped.

## Claude workflow for Vendure tickets

Apply for every OSS/PDEV ticket that produces a code change. Skip the steps that don't apply (e.g. no UI to smoke), but never skip silently — say so.

1. **Plan first.** For anything beyond a one-line fix, show the diff + test plan and wait for explicit OK before writing code.
2. **Reviewers.** Spawn `nigel:nigel` and `vendurebot:vendurebot` in parallel on the proposed diff. Apply HIGH and MEDIUM feedback before commit; document why anything was skipped.
3. **Tests are mandatory.** Every PR ships with at least one automated regression. Default location: `packages/dashboard/e2e/tests/regression/issue-<NNNN>-<slug>.spec.ts`. The test must pass on the branch and fail on master — verify both. For prod-only behaviour (e.g. lingui warnings) or component-level edge cases not exercised by e2e, document the gap and the manual repro steps.
4. **Manual smoke for UI.** Drive the user's running dashboard on `localhost:5174` via `chrome-devtools` MCP (not a self-started backend). Clean up any test data through the same flow.
5. **Commit only after explicit OK from the user.** A failing test, a passing test, a successful manual smoke — none of those imply permission to commit. Wait.
6. **One commit per logical change.** Use the existing conventional-commit shape (`feat(dashboard):`, `fix(dashboard):`, `test(dashboard):`). Body explains *why*; ends with `Fixes #<gh-number>`.
7. **Push + open PR** after commit. PR body lists: summary, root cause, change, test plan with what passed automatically vs manually, and any follow-ups out of scope.
8. **Linear sync.** Move the ticket to `In Review`, post a comment with the PR link, a summary of the fix, and the verification (what tests, what smoke). Mirror any follow-ups as a separate comment.
9. **Recover, don't paper over.** If something breaks (lost stash, killed port, polluted DB), say so up front and propose the recovery path before continuing.
30 changes: 28 additions & 2 deletions packages/dashboard/e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
registerInitializer,
SqljsInitializer,
} from '@vendure/testing';
import { mkdirSync, writeFileSync } from 'node:fs';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

Expand Down Expand Up @@ -48,14 +48,40 @@ export default async function globalSetup() {
CustomHistoryEntryPlugin: new () => unknown;
}>(path.join(__dirname, 'fixtures', 'custom-history-entry-plugin.ts'));

// AssetServerPlugin uses NestJS decorators and class-extends-from-core
// patterns that Playwright's static-ESM import path cannot resolve
// (hashed-asset-naming-strategy extends a base class from @vendure/core
// that ends up `undefined` under Babel transform). Pull it in via Node's
// native loader at runtime to side-step the transform path.
const { AssetServerPlugin } =
(await import('@vendure/asset-server-plugin')) as typeof import('@vendure/asset-server-plugin');

// Assets uploaded via createAssets during tests are written here. Wipe on
// each setup so reruns start from a clean directory; the suite assumes
// no asset state survives between full test runs.
const assetUploadDir = path.join(__dirname, '__data__/assets');
rmSync(assetUploadDir, { recursive: true, force: true });
mkdirSync(assetUploadDir, { recursive: true });

const config = mergeConfig(defaultTestConfig, {
apiOptions: {
port: VENDURE_PORT,
},
paymentOptions: {
paymentMethodHandlers: e2ePaymentMethodHandlers,
},
plugins: [CustomHistoryEntryPlugin],
// AssetServerPlugin is required so that uploaded assets resolve to a
// proper http URL (the default test-asset storage strategy emits a
// `test-url/test-assets/...` placeholder that `VendureImage` cannot
// parse with `new URL(...)`). Tests that exercise the asset preview
// dialog need this to render.
plugins: [
CustomHistoryEntryPlugin,
// Cast to any — the dynamic-import return type cannot satisfy the
// mergeConfig `DeepPartial<plugin>` shape that includes nestjs
// DynamicModule, but the runtime value is correct.
AssetServerPlugin.init({ route: 'assets', assetUploadDir }) as any,
],
customFields: e2eCustomFields,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { expect, test } from '@playwright/test';

import { VENDURE_PORT } from '../../constants.js';

// #4722 — The focal-point editor was only available on the standalone Asset
// detail route. The fix wires it through the shared `AssetPreview` (and
// therefore the dialog used by Product / Variant detail pages), with a
// callback up to `EntityAssets` so re-opening the dialog after a save shows
// the new value rather than the stale one from the parent detail query.
test.describe('Issue 4722 — focal point editor in shared asset preview dialog', () => {
interface SetupResult {
productId: string;
assetId: string;
originalFocalPoint: { x: number; y: number } | null;
}

let setup: SetupResult;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

test.beforeEach(async ({ page }) => {
await page.goto('/');
setup = await page.evaluate(async vendurePort => {
const apiUrl = `http://localhost:${vendurePort}/admin-api`;
const sessionToken = localStorage.getItem('vendure-session-token');
if (!sessionToken) throw new Error('No vendure-session-token');
const post = async (query: string, variables: Record<string, unknown>) => {
const res = await fetch(apiUrl, {
method: 'POST',
credentials: 'include',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${sessionToken}`,
},
body: JSON.stringify({ query, variables }),
});
const json = await res.json();
if (json.errors?.length) throw new Error(`Admin API: ${JSON.stringify(json.errors)}`);
return json.data;
};

// The e2e populate seeds products but not assets, so upload a
// 1×1 transparent PNG via the multipart `createAssets` mutation.
const png1x1Base64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const binary = atob(png1x1Base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const formData = new FormData();
formData.append(
'operations',
JSON.stringify({
query: `mutation($input: [CreateAssetInput!]!) {
createAssets(input: $input) {
...on Asset { id name focalPoint { x y } }
...on MimeTypeError { message }
}
}`,
variables: { input: [{ file: null }] },
}),
);
formData.append('map', JSON.stringify({ '0': ['variables.input.0.file'] }));
formData.append('0', new Blob([bytes], { type: 'image/png' }), `oss-530-${Date.now()}.png`);
const uploadRes = await fetch(apiUrl, {
method: 'POST',
credentials: 'include',
headers: { authorization: `Bearer ${sessionToken}` },
body: formData,
});
const uploadJson = await uploadRes.json();
if (uploadJson.errors?.length) {
throw new Error(`Asset upload: ${JSON.stringify(uploadJson.errors)}`);
}
const seedAsset = uploadJson.data.createAssets.find((a: any) => a.id);
if (!seedAsset) {
throw new Error(`Asset upload returned no Asset: ${JSON.stringify(uploadJson.data)}`);
}

// Spin up a dedicated product so the test is isolated from
// anything else mutating the seed products.
const ts = Date.now();
const product = await post(
`mutation($input: CreateProductInput!) { createProduct(input: $input) { id } }`,
{
input: {
featuredAssetId: seedAsset.id,
assetIds: [seedAsset.id],
translations: [
{
languageCode: 'en',
name: `OSS-530 Test Product ${ts}`,
slug: `oss-530-${ts}`,
description: '',
},
],
},
},
);

return {
productId: product.createProduct.id as string,
assetId: seedAsset.id as string,
originalFocalPoint: seedAsset.focalPoint ?? null,
};
}, VENDURE_PORT);
});

test.afterEach(async ({ page }) => {
// Reset the asset back to its original focal point so the seed isn't
// polluted across runs in the (rare) case that the playwright config
// reuses the global setup DB.
await page.evaluate(
async args => {
const { vendurePort, assetId, originalFocalPoint } = args;
const apiUrl = `http://localhost:${vendurePort}/admin-api`;
const sessionToken = localStorage.getItem('vendure-session-token');
if (!sessionToken) return;
await fetch(apiUrl, {
method: 'POST',
credentials: 'include',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${sessionToken}`,
},
body: JSON.stringify({
query: `mutation($input: UpdateAssetInput!) { updateAsset(input: $input) { id } }`,
variables: { input: { id: assetId, focalPoint: originalFocalPoint } },
}),
});
},
{
vendurePort: VENDURE_PORT,
assetId: setup.assetId,
originalFocalPoint: setup.originalFocalPoint,
},
);
});

test('should let the user set a focal point from the preview dialog and persist across re-open', async ({
page,
}) => {
test.setTimeout(45_000);

await page.goto(`/products/${setup.productId}`);

// The Assets PageBlock in EntityAssets renders the featured asset
// inside a `<div data-testid="entity-assets-featured">` wrapper. Target
// its <img> directly so the test doesn't depend on the asset server's
// URL scheme.
const featuredImage = page.getByTestId('entity-assets-featured').locator('img');
await expect(featuredImage).toBeVisible({ timeout: 15_000 });
await featuredImage.click();

// The preview dialog opens with the new "Set focal point" button.
const setFocalPointTrigger = page.getByTestId('asset-preview-set-focal-point');
await expect(setFocalPointTrigger).toBeVisible({ timeout: 5_000 });

const focalPointValue = page.getByTestId('asset-preview-focal-point-value');
// Activate the focal-point editor and confirm with the default centre
// position the editor renders for an asset without a saved focal point.
await setFocalPointTrigger.click();
await page.getByTestId('asset-focal-point-editor-confirm').click();

// After the mutation, the coords readout updates (the toast assertion
// is skipped — sonner auto-dismisses too quickly to race against
// reliably and the coords readout is the load-bearing signal).
await expect(focalPointValue).toContainText('0.50, 0.50', { timeout: 10_000 });

// Close the dialog (Escape) and re-open — the indicator must still
// show the saved coords, not regress to the stale parent value.
// Before the parent-sync fix, EntityAssets' local `assets` array would
// still hold the pre-save focal point, so the re-opened dialog would
// misreport "Not set" or stale coords.
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toBeHidden({ timeout: 5_000 });

await featuredImage.click();
await expect(focalPointValue).toContainText('0.50, 0.50', { timeout: 5_000 });
});
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Button } from '@/vdb/components/ui/button.js';
import { Trans } from '@lingui/react/macro';
import { cn } from '@/vdb/lib/utils.js';
import { DndContext, useDraggable } from '@dnd-kit/core';
import { restrictToParentElement } from '@dnd-kit/modifiers';
import { CSS } from '@dnd-kit/utilities';
import { Trans } from '@lingui/react/macro';
import { Crosshair, X } from 'lucide-react';
import { useState } from 'react';

Expand Down Expand Up @@ -89,6 +89,7 @@ export function AssetFocalPointEditor({
</Button>
<Button
type="button"
data-testid="asset-focal-point-editor-confirm"
onClick={() => {
onFocalPointChange(focalPointCurrent);
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
DialogHeader,
DialogTitle,
} from '@/vdb/components/ui/dialog.js';
import { Trans } from '@lingui/react/macro';
import { ComponentProps } from 'react';
import { AssetPreview, AssetWithTags } from './asset-preview.js';

interface AssetPreviewDialogProps {
Expand All @@ -13,6 +15,7 @@ interface AssetPreviewDialogProps {
asset: AssetWithTags;
assets?: AssetWithTags[];
customFields?: any[];
onAssetUpdated?: ComponentProps<typeof AssetPreview>['onAssetUpdated'];
}

export function AssetPreviewDialog({
Expand All @@ -21,16 +24,26 @@ export function AssetPreviewDialog({
asset,
assets,
customFields,
onAssetUpdated,
}: AssetPreviewDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[800px] lg:max-w-[95vw] w-[95vw] p-0">
<DialogHeader className="p-6 pb-0">
<DialogTitle>Asset</DialogTitle>
<DialogDescription>Preview of {asset.name}</DialogDescription>
<DialogTitle>
<Trans>Asset</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Preview of {asset.name}</Trans>
</DialogDescription>
</DialogHeader>
<div className="h-full p-6">
<AssetPreview asset={asset} assets={assets} customFields={customFields} />
<AssetPreview
asset={asset}
assets={assets}
customFields={customFields}
onAssetUpdated={onAssetUpdated}
/>
</div>
</DialogContent>
</Dialog>
Expand Down
Loading
Loading