Note
Access to this page requires authorization. You can try signing in or changing directories.
Access to this page requires authorization. You can try changing directories.
Custom pages are canvas apps embedded inside a model-driven app. They render in an iframe within the model-driven app shell. Testing them requires navigating to the model-driven app, selecting the custom page from the sitemap, and then scoping all control interactions to the inner iframe.
How custom page testing works in model-driven apps
When a custom page loads, the model-driven app shell remains on the Dynamics 365 domain. The custom page canvas runtime loads inside:
iframe[name="fullscreen-app-host"]
This is the same iframe used by standalone canvas apps. Once you have the frame locator, all canvas app testing patterns apply.
Navigate to a custom page
- Launch the model-driven app using
AppProvider. - Select the custom page item in the sitemap.
- Wait for the canvas runtime to initialize.
import { test, expect } from '@playwright/test';
import { AppProvider, AppType, AppLaunchMode } from 'power-platform-playwright-toolkit';
const MODEL_DRIVEN_APP_URL = process.env.MODEL_DRIVEN_APP_URL!;
const CUSTOM_PAGE_NAME = process.env.CUSTOM_PAGE_NAME ?? 'AccountsCustomPage';
test.beforeAll(async ({ browser }) => {
const context = await browser.newContext({ storageState: mdaStorageStatePath });
const page = await context.newPage();
const app = new AppProvider(page, context);
await app.launch({
app: 'My App',
type: AppType.ModelDriven,
mode: AppLaunchMode.Play,
skipMakerPortal: true,
directUrl: MODEL_DRIVEN_APP_URL,
});
// Navigate to the custom page via the sitemap
const sidebarItem = page
.locator(`[role="presentation"][title="${CUSTOM_PAGE_NAME}"]`)
.first();
await sidebarItem.waitFor({ state: 'visible', timeout: 30000 });
await sidebarItem.click();
await page.waitForTimeout(3000);
});
Interact with custom page controls
After navigating to the custom page, scope locators to the canvas iframe:
const canvasFrame = page.frameLocator('iframe[name="fullscreen-app-host"]');
// Wait for gallery to appear
await canvasFrame
.locator('[data-control-part="gallery-item"]')
.first()
.waitFor({ state: 'visible', timeout: 30000 });
Click buttons in custom page controls
Use the data-control-name attribute to target specific button controls within the canvas iframe, then locate the inner [role="button"] element to trigger a click action.
await canvasFrame.locator('[data-control-name="IconButton_Accept1"] [role="button"]').click();
await canvasFrame.locator('[data-control-name="IconButton_Edit1"] [role="button"]').click();
Fill form fields in a custom page
Locate input fields by their aria-label attribute within the canvas iframe and use the fill method to enter values.
const accountNameInput = canvasFrame.locator('input[aria-label="Account Name"]');
await accountNameInput.fill('Contoso Ltd');
Filter a gallery by title in a custom page
To find a specific item in a gallery, filter the list of gallery items by matching the text content of a child control such as Title1.
const galleryItem = canvasFrame
.locator('[role="listitem"][data-control-part="gallery-item"]')
.filter({
has: canvasFrame
.locator('[data-control-name="Title1"]')
.getByText('Contoso Ltd', { exact: true }),
});
await galleryItem.waitFor({ state: 'visible', timeout: 30000 });
Refresh the custom page after a save
When you save a new record in a custom page backed by Dataverse, the gallery doesn't refresh automatically unless you trigger a full reload. The recommended approach is to go to the app root and back:
// Navigate to app root to force gallery refresh
await page.goto(MODEL_DRIVEN_APP_URL, { waitUntil: 'load', timeout: 60000 });
await page.locator('[role="menuitem"]').first().waitFor({ state: 'visible', timeout: 30000 });
// Navigate back to the custom page
const sidebarItem = page.locator(`[role="presentation"][title="${CUSTOM_PAGE_NAME}"]`).first();
await sidebarItem.waitFor({ state: 'visible', timeout: 30000 });
await sidebarItem.click();
// Wait for the new record to appear in the gallery
const specificItem = page
.locator('[data-control-part="gallery-item"]')
.filter({ has: page.locator('[data-control-name="Title1"]').getByText(accountName, { exact: true }) });
await specificItem.waitFor({ state: 'visible', timeout: 60000 });
Full test example: create and verify a record
The following example combines navigation, form entry, save, and gallery verification into a single end-to-end test that creates an account record and confirms it appears in the custom page gallery.
test('should create an account and verify it in the gallery', async ({ page }) => {
const ACCOUNT_NAME = `Test Account ${Date.now()}`;
const canvasFrame = page.frameLocator('iframe[name="fullscreen-app-host"]');
// Click New record
await page.locator('[title="New record"]').click();
// Fill the form
await canvasFrame.locator('input[aria-label="Account Name"]').fill(ACCOUNT_NAME);
await canvasFrame.locator('input[aria-label="Main Phone"]').fill('555-1234');
// Save
await canvasFrame
.locator('[data-control-name="IconButton_Accept1"] [role="button"]')
.click();
// Refresh and verify
await page.waitForTimeout(5000); // wait for Dataverse write
await page.goto(MODEL_DRIVEN_APP_URL, { waitUntil: 'load', timeout: 60000 });
await page.locator('[role="menuitem"]').first().waitFor({ timeout: 30000 });
await page.locator(`[role="presentation"][title="${CUSTOM_PAGE_NAME}"]`).first().click();
await expect(
page
.locator('[data-control-part="gallery-item"]')
.filter({ has: page.locator('[data-control-name="Title1"]').getByText(ACCOUNT_NAME, { exact: true }) })
).toBeVisible({ timeout: 60000 });
});
Authentication for custom pages
Custom pages run on the Dynamics 365 domain. Use the model-driven app storage state:
test.use({
storageState: path.join(
path.dirname(getStorageStatePath(process.env.MS_AUTH_EMAIL!)),
`state-mda-${process.env.MS_AUTH_EMAIL}.json`
),
});