Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .changeset/brave-popup-listings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fluentui-react-native/desktop-driver': patch
---

Keep Windows and Win32 window listings available when a secondary window closes between discovery and native geometry serialization, without hiding failures for live windows.
5 changes: 5 additions & 0 deletions .changeset/native-callout-window-command-identities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fluentui-react-native/native-core': patch
---

Give the legacy Callout window-command story's focus and blur buttons stable test identities for unique secondary-window input qualification.
5 changes: 5 additions & 0 deletions .changeset/quiet-menu-windows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fluentui-react-native/desktop-driver': patch
---

Add a session-scoped, non-activating window tree query so consumers can distinguish legitimate secondary menus from unexpected dialogs without dismissing the menu.
10 changes: 10 additions & 0 deletions .changeset/windows-connected-session-qualification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@fluentui-react-native/desktop-driver": patch
"@fluentui-react-native/desktop-driver-mcp": patch
"@fluentui-react-native/desktop-test": patch
"@fluentui-react-native/storybook-desktop": patch
---

Record Windows Fabric and Win32 Paper connected-session qualification,
physical MCP click costs, explicit untested display scenarios, and the
remaining secondary-window semantic activation and registration gaps.
9 changes: 9 additions & 0 deletions .changeset/windows-connected-session-refusals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@fluentui-react-native/desktop-driver': patch
---

Refuse Windows legacy-only semantic activation before dispatch, preserving machine-readable reasons without recording a delivery or implicitly retrying with physical input.

Register untitled Windows secondary tool popups only when their exact-process ownership and bounded UIA content are verified, retaining helper-window exclusions.

Keep admitted popups listable through transient provider failures, share one bounded discovery deadline across new candidates, and include admitted popups in publication diagnostics.
11 changes: 11 additions & 0 deletions apps/E2E/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@ Native setup retains initial
window bounds: it does **not** emulate the old maximize hook. Popup lookup
remains scoped to the selected window.

On Windows and Win32, the assertion-dialog safety check inspects each secondary
window without selecting or activating it, so it cannot dismiss an open menu.
Only a single accessibility `menu` root without nested dialogs or windows is
recognized as an expected popup. A tree read reporting `no such window` is
treated as a closed popup. Empty or unrecognized roots fail the check only
when a fresh window listing still includes that handle, avoiding false
assertions while a menu closes. Every other tree-read error still fails the
test rather than being treated as no assertion.
The native-task-library bypass, Android page check, and other runners' existing
window-count check are unchanged.

WebdriverIO 10 throws a `StrictSelectorError` when a selector matches several
elements, where WebdriverIO 9 took the first match. A native tree can expose
several elements for one `testID` (a container and its children), and the page
Expand Down
151 changes: 150 additions & 1 deletion apps/E2E/desktop-adapters.test.cjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,162 @@
const assert = require('node:assert/strict');
const { afterEach, test } = require('node:test');
const { afterEach, mock, test } = require('node:test');
const { BasePage } = require('./lib/common/BasePage.js');
const { Keys } = require('./lib/common/consts.js');
const { installLegacyDesktopAttributes, scrollNativeDesktopElement, sendNativeDesktopKeys } = require('./lib/common/nativeDesktop.js');

afterEach(() => {
mock.restoreAll();
delete globalThis.browser;
});

test('Windows assertion checks inspect menu windows without selecting or activating them', async () => {
const reads = [];
globalThis.browser = {
capabilities: { browserName: 'furn-native-desktop', platformName: 'windows', 'furn:endpoint': 'win32' },
options: { protocol: 'http', hostname: '127.0.0.1', port: 1, path: '/' },
sessionId: 'session',
getWindowHandles: async () => ['main', 'menu'],
getWindowHandle: async () => 'main',
switchToWindow: () => assert.fail('an assertion check must not change native focus'),
};
mock.method(globalThis, 'fetch', async (url, init) => {
reads.push(String(url));
assert.equal(init.method, 'GET');
return new Response(JSON.stringify({ value: [{ role: 'menu', children: [{ role: 'menuitem', children: [] }] }] }));
});
assert.equal(await new BasePage().didAssertPopup(), false);
// eslint-disable-next-line @microsoft/sdl/no-insecure-url -- mocked loopback transport; no service is contacted
assert.deepEqual(reads, ['http://127.0.0.1:1/session/session/furn/window/menu/tree']);
browser.getWindowHandles = async () => ['main'];
browser.getWindowHandle = () => assert.fail('one window needs no further inspection');
assert.equal(await new BasePage().didAssertPopup(), false);
assert.equal(reads.length, 1);
});

test('Windows assertion checks fail closed for dialogs, unrecognized roots, and unreadable windows', async () => {
globalThis.browser = {
capabilities: { browserName: 'furn-native-desktop', platformName: 'windows', 'furn:endpoint': 'windows' },
options: { protocol: 'http', hostname: '127.0.0.1', port: 1, path: '/' },
sessionId: 'session',
getWindowHandles: async () => ['main', 'other'],
getWindowHandle: async () => 'main',
switchToWindow: () => assert.fail('an assertion check must not change native focus'),
};
let value;
const reader = mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify({ value })));
const page = new BasePage();
for (const roots of [
[{ role: 'window', name: 'Assertion failed', children: [] }],
[{ role: 'group', children: [] }],
[],
[{ role: 'menu', children: [{ role: 'dialog', children: [] }] }],
[{ role: 'menu', children: [{ role: 'window', children: [] }] }],
[
{ role: 'menu', children: [] },
{ role: 'dialog', children: [] },
],
]) {
value = roots;
assert.equal(await page.didAssertPopup(), true);
}
reader.mock.mockImplementation(async () => {
throw new Error('provider is unavailable');
});
await assert.rejects(page.didAssertPopup(), /provider is unavailable/);
});

test('Windows assertion checks do not let an open menu hide another unexpected window', async () => {
globalThis.browser = {
capabilities: { browserName: 'furn-native-desktop', platformName: 'windows', 'furn:endpoint': 'win32' },
options: { protocol: 'http', hostname: '127.0.0.1', port: 1, path: '/' },
sessionId: 'session',
getWindowHandles: async () => ['main', 'menu', 'assert'],
getWindowHandle: async () => 'main',
};
mock.method(
globalThis,
'fetch',
async (url) =>
new Response(
JSON.stringify({
value: [{ role: String(url).endsWith('/menu/tree') ? 'menu' : 'window', children: [] }],
}),
),
);
assert.equal(await new BasePage().didAssertPopup(), true);
});

test('Windows assertion checks skip a vanished menu and continue checking other windows', async () => {
let handles = ['main', 'closing-menu'];
globalThis.browser = {
capabilities: { browserName: 'furn-native-desktop', platformName: 'windows', 'furn:endpoint': 'win32' },
options: { protocol: 'http', hostname: '127.0.0.1', port: 1, path: '/' },
sessionId: 'session',
getWindowHandles: async () => handles,
getWindowHandle: async () => 'main',
switchToWindow: () => assert.fail('an assertion check must not change native focus'),
};
mock.method(globalThis, 'fetch', async (url) =>
String(url).endsWith('/closing-menu/tree')
? new Response(JSON.stringify({ value: { error: 'no such window', message: 'The menu closed.' } }), { status: 404 })
: new Response(JSON.stringify({ value: [{ role: 'window', name: 'Assertion failed', children: [] }] })),
);
const page = new BasePage();
assert.equal(await page.didAssertPopup(), false);
handles = ['main', 'closing-menu', 'assert'];
assert.equal(await page.didAssertPopup(), true);
});

test('Windows assertion checks recheck an empty or unrecognized root before reporting an assertion', async () => {
let lists = 0;
globalThis.browser = {
capabilities: { browserName: 'furn-native-desktop', platformName: 'windows', 'furn:endpoint': 'win32' },
options: { protocol: 'http', hostname: '127.0.0.1', port: 1, path: '/' },
sessionId: 'session',
getWindowHandles: async () => (++lists === 1 ? ['main', 'closing-menu'] : ['main']),
getWindowHandle: async () => 'main',
switchToWindow: () => assert.fail('an assertion check must not change native focus'),
};
let value;
mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify({ value })));
const page = new BasePage();
for (const roots of [[], [{ role: 'group', children: [] }], [{ role: 'menu', children: [{ role: 'dialog', children: [] }] }]]) {
lists = 0;
value = roots;
assert.equal(await page.didAssertPopup(), false);
}
});

test('Windows assertion checks do not treat other provider errors as closed windows', async () => {
globalThis.browser = {
capabilities: { browserName: 'furn-native-desktop', platformName: 'windows', 'furn:endpoint': 'win32' },
options: { protocol: 'http', hostname: '127.0.0.1', port: 1, path: '/' },
sessionId: 'session',
getWindowHandles: async () => ['main', 'unreadable-menu'],
getWindowHandle: async () => 'main',
};
let code;
mock.method(
globalThis,
'fetch',
async () => new Response(JSON.stringify({ value: { error: code, message: 'The provider could not read the tree.' } }), { status: 500 }),
);
for (const error of ['unknown error', 'stale element reference']) {
code = error;
await assert.rejects(new BasePage().didAssertPopup(), { code: error });
}
});

test('assertion checks retain non-native and macOS window-count behavior', async () => {
for (const capabilities of [
{ browserName: 'non-native' },
{ browserName: 'furn-native-desktop', platformName: 'macos', 'furn:endpoint': 'macos' },
]) {
globalThis.browser = { capabilities, getWindowHandles: async () => ['main', 'other'] };
assert.equal(await new BasePage().didAssertPopup(), true);
}
});

test('native checkbox and switch state never falls back to selected', async () => {
const page = new BasePage();
const element = { elementId: 'toggle', isSelected: () => assert.fail('checked is not selected') };
Expand Down
12 changes: 7 additions & 5 deletions apps/E2E/src/common/BasePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
TESTPAGE_BUTTONS_SCROLLVIEWER,
TESTPAGE_CONTENT_SCROLLVIEWER,
} from './consts.js';
import { isNativeDesktop, scrollNativeDesktopElement, sendNativeDesktopKeys } from './nativeDesktop.js';
import { hasUnexpectedNativeDesktopWindow, isNativeDesktop, scrollNativeDesktopElement, sendNativeDesktopKeys } from './nativeDesktop.js';
import type { PageElement } from './nativeDesktop.js';

const DUMMY_CHAR = '';
Expand Down Expand Up @@ -440,9 +440,9 @@ export abstract class BasePage {
* WHY/HOW: Unfortunately, some Asserts don't take focus away from the test app. Because of this, the test execution is able
* to continue without realizing an assert as popped up (unless the assert is a crashing one).
*
* In order to detect an assert, this function gets the number of window handles currently open within the test app.
* Only one instance SHOULD be open at a time (the app itself). If another instance of the app is open, we know an assert dialogue
* has popped up. With this information, we know to fail the test.
* Unexpected secondary windows can indicate a non-focusing assert. Windows native menus are legitimate secondary
* windows, so inspect their accessibility roots without changing focus. Unrecognized windows still fail the check,
* and an unreadable window fails the test rather than silently claiming no assert.
*
* Additionally, you might think it's logical to simply place this in the afterEach() hook so it's called after every test (rather than duplicating this call in every test).
* Unfortunately, afterEach() is designed for setup/teardown - not for determining if a test should fail or not.
Expand All @@ -456,8 +456,10 @@ export abstract class BasePage {
if (PLATFORM === 'android') {
return !(await this.isPageLoaded());
}
// If more than 1 instance of the app is open, we know an assert dialogue popped up.
const windowHandles = await browser.getWindowHandles();
if (isNativeDesktop() && browser.capabilities.platformName === 'windows') {
return hasUnexpectedNativeDesktopWindow(windowHandles);
}
return windowHandles.length > 1;
}

Expand Down
38 changes: 38 additions & 0 deletions apps/E2E/src/common/nativeDesktop.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createDesktopDriverClient, DesktopSessionClient, WebDriverError } from '@fluentui-react-native/desktop-driver';
import type { DesktopTreeNode } from '@fluentui-react-native/desktop-driver';
import { Keys } from './consts.js';

/** A page object getter, or the element it has already resolved to. */
Expand All @@ -7,6 +9,42 @@ export function isNativeDesktop(): boolean {
return browser.capabilities.browserName === 'furn-native-desktop';
}

export async function hasUnexpectedNativeDesktopWindow(handles: readonly string[]): Promise<boolean> {
if (handles.length <= 1) return false;
const current = await browser.getWindowHandle();
const url = `${browser.options.protocol}://${browser.options.hostname}:${browser.options.port}${browser.options.path}`;
const session = new DesktopSessionClient(
createDesktopDriverClient({ url }),
browser.sessionId,
Object.fromEntries(Object.entries(browser.capabilities)),
);
for (const handle of handles) {
if (handle === current) continue;
// Switching windows would activate the target and can dismiss the menu being tested.
let roots: DesktopTreeNode[];
try {
roots = await session.getWindowTree(handle);
} catch (error) {
if (error instanceof WebDriverError && error.code === 'no such window') continue;
throw error;
}
// A closing popup can lose its content before its window disappears.
if (!isExpectedMenuTree(roots) && (await browser.getWindowHandles()).includes(handle)) return true;
}
return false;
}

function isExpectedMenuTree(roots: readonly DesktopTreeNode[]): boolean {
if (roots.length !== 1 || roots[0].role !== 'menu') return false;
const pending = [...roots];
while (pending.length > 0) {
const node = pending.pop()!;
if (node.role === 'dialog' || node.role === 'window') return false;
pending.push(...node.children);
}
return true;
}

export async function sendNativeDesktopKeys(element: PageElement, keys: readonly Keys[]): Promise<void> {
const target = await element;
await browser.elementSendKeys(await target.elementId, '');
Expand Down
Loading