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
33 changes: 28 additions & 5 deletions apps/netscli-gui/e2e/tauri-render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,24 @@ let nativeDriverProcess;
let appProcess;
let probeServer;

/** Progress for the startup sequence.
*
* Between the build and the first scenario this harness used to print
* nothing, so a stall in setup was indistinguishable from a clean exit: the
* local symptom was "builds, then exits 0", and diagnosing it needed
* instrumentation added by hand. These lines are cheap and make the CI log
* say where it got to. */
function step(label) {
console.log(`[setup] ${label}`);
}

async function main() {
let webdriverPort = process.env.TAURI_DRIVER_PORT ? Number(process.env.TAURI_DRIVER_PORT) : 0;
if (!webdriverPort) {
webdriverPort = await getFreePort();
}
// The WebDriver port is NOT chosen here. startNativeDriver picks it at the
// moment it spawns, because a port chosen now would be minutes stale by
// then -- see the note on that function.
const fixedDriverPort = process.env.TAURI_DRIVER_PORT
? Number(process.env.TAURI_DRIVER_PORT)
: 0;

const usingExternalApp = Boolean(process.env.TAURI_APP_PATH);
if (usingExternalApp) {
Expand All @@ -67,18 +80,27 @@ async function main() {
await run(npmBin, ['run', 'build']);
}

step("probe server");
const { server, port } = await startProbeServer();
probeServer = server;

step("resolve native driver");
const nativeDriverPath = await resolveNativeDriverPath();
step("find app binary");
const application = findApplication();
process.env.NETSCLI_EXPORT_DIR = artifactsDir;
// We start the app, so we choose its debug port; the driver then attaches
// to it rather than launching anything. See driver.mjs for why.
step("pick debug port");
const debugPort = await getFreePort();
step("launch app");
appProcess = await launchApplication(application, debugPort);
nativeDriverProcess = await startNativeDriver(nativeDriverPath, webdriverPort);
step("start native driver");
const started = await startNativeDriver(nativeDriverPath, fixedDriverPort);
nativeDriverProcess = started.child;
const webdriverPort = started.port;

step("create webdriver session");
let driver;
try {
driver = await createDriver(webdriverPort, debugPort);
Expand All @@ -96,6 +118,7 @@ async function main() {
throw error;
}

step("session created; setting viewport");
try {
await setViewport(debugPort, DESKTOP_WINDOW);
await withElement(driver, '[data-testid="app-shell"]', 20_000);
Expand Down
90 changes: 77 additions & 13 deletions apps/netscli-gui/e2e/tauri-render/driver.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { download as downloadEdgeDriver } from 'edgedriver';
import webdriver from 'selenium-webdriver';

import { guiRoot, repoRoot } from './paths.mjs';
import { waitForPort } from './processes.mjs';
import { getFreePort, stopProcess, waitForPort } from './processes.mjs';

export const { Builder, By, Key, until } = webdriver;

Expand Down Expand Up @@ -93,20 +93,39 @@ export async function launchApplication(application, debugPort) {
child.getOutput = () => output;
child.profileDir = profileDir;

await Promise.race([
waitForPort(debugPort, 60_000),
new Promise((_, reject) => {
child.once('exit', (code, signal) => {
reject(new Error(`app exited before opening its debug port (${code ?? signal})\n${output}`));
});
}),
]);
// Kill the app if it never becomes usable.
//
// This used to let the rejection escape with the child still running, and
// the child is spawned with piped stdio, so those pipes kept node's event
// loop alive: the CI job printed "Timed out waiting for port" and then sat
// there for 21 more minutes until the 30-minute job cap killed it. The
// caller cannot clean this up, because `appProcess` is only assigned from
// this function's return value -- on the throw path it stays undefined and
// `stopProcess(appProcess)` is a no-op.
try {
await Promise.race([
waitForPort(debugPort, 60_000),
new Promise((_, reject) => {
child.once('exit', (code, signal) => {
reject(new Error(`app exited before opening its debug port (${code ?? signal})\n${output}`));
});
}),
]);
} catch (error) {
stopProcess(child);
throw error;
}

return child;
}

/** Run the platform WebDriver directly; `tauri-driver` is not in the path. */
export async function startNativeDriver(nativeDriverPath, webdriverPort) {
/** msedgedriver's own words when the port it was given is already bound. */
function isPortCollision(message) {
return /bind\(\) returned an error|port not available/i.test(message);
}

function spawnNativeDriver(nativeDriverPath, webdriverPort) {
let output = '';
const child = spawn(
nativeDriverPath,
Expand All @@ -118,16 +137,61 @@ export async function startNativeDriver(nativeDriverPath, webdriverPort) {
child.stderr.on('data', (chunk) => { output += chunk.toString(); });
child.getDriverOutput = () => output;

await Promise.race([
return Promise.race([
waitForPort(webdriverPort),
new Promise((_, reject) => {
child.once('exit', (code, signal) => {
reject(new Error(`native driver exited early with ${code ?? signal}\n${output}`));
});
}),
]);
])
.then(() => child)
.catch((error) => {
stopProcess(child);
throw error;
});
}

return child;
/**
* Start msedgedriver, choosing its port HERE rather than accepting one chosen
* earlier.
*
* The port used to be picked at the top of main(), then handed to this
* function minutes later -- after the Rust build and after the app launched.
* `getFreePort` reserves nothing: it binds an ephemeral port, reads it, and
* closes, so the number is only a fact about the instant it was taken.
* WebView2 starts a swarm of processes that take ephemeral ports, and one of
* them would take that one. msedgedriver then failed to bind and exited 1
* after printing only its banner:
*
* [SEVERE]: bind() returned an error: Only one usage of each socket
* address (protocol/network address/port) is normally permitted. (0x2740)
* IPv6 port not available. Exiting...
*
* Reproduced directly by holding the port open and spawning the driver on it.
*
* Picking it here shrinks the window to milliseconds, and the retry closes
* what is left: the race cannot be eliminated, only made small and survivable.
* An explicit TAURI_DRIVER_PORT is honoured and never retried -- if a port was
* named, failing to get it is the answer, not a reason to use a different one.
*/
export async function startNativeDriver(nativeDriverPath, fixedPort) {
const attempts = fixedPort ? 1 : 3;
let lastError;

for (let attempt = 1; attempt <= attempts; attempt += 1) {
const port = fixedPort || (await getFreePort());
try {
const child = await spawnNativeDriver(nativeDriverPath, port);
return { child, port };
} catch (error) {
lastError = error;
if (!isPortCollision(error.message)) throw error;
console.warn(`native driver could not bind port ${port}; retrying (${attempt}/${attempts})`);
}
}

throw lastError;
}

export async function createDriver(webdriverPort, debugPort) {
Expand Down
134 changes: 85 additions & 49 deletions apps/netscli-gui/e2e/tauri-render/scenarios/helpers/interaction.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,39 +68,21 @@ async function assertThemedTooltips(driver) {
assert.equal(state.nativeTitles.length, 0, `Native title tooltips should not be used: ${state.nativeTitles.join(', ')}`);
assert.ok(state.tooltipCount >= 6, `Expected themed tooltip hooks, got ${state.tooltipCount}`);
assert.equal(state.disabledTooltipHostOpacity, '1', 'Disabled toolbar buttons should not fade their themed tooltips');
await dispatchTooltipPointerOver(driver, '[data-testid="run-active-tab"]');
await waitForText(driver, '[data-testid="app-tooltip"]', /Start Scan|Run|Lookup/i);
const tooltipLayer = await driver.executeScript(`
const tooltip = document.querySelector('[data-testid="app-tooltip"]');
if (!tooltip) return null;
return {
position: getComputedStyle(tooltip).position,
zIndex: Number(getComputedStyle(tooltip).zIndex),
};
`);
assert.ok(tooltipLayer, 'Global tooltip should render');
assert.equal(tooltipLayer.position, 'fixed', 'Tooltips should be fixed-layer, not clipped by tab overflow');
assert.ok(tooltipLayer.zIndex >= 1000, `Tooltip should sit above app overlays, got z-index ${tooltipLayer.zIndex}`);
await dispatchTooltipPointerOver(driver, '.detail-actions button:last-child');
await waitForText(driver, '[data-testid="app-tooltip"]', /details pane/i);
const tooltipBounds = await driver.executeScript(`
const tooltip = document.querySelector('[data-testid="app-tooltip"]');
if (!tooltip) return null;
const rect = tooltip.getBoundingClientRect();
return {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
width: window.innerWidth,
height: window.innerHeight,
};
`);
assert.ok(tooltipBounds, 'Tooltip bounds should be measurable');
assert.ok(tooltipBounds.left >= 0, `Tooltip should not be clipped on the left: ${tooltipBounds.left}`);
assert.ok(tooltipBounds.right <= tooltipBounds.width, `Tooltip should not be clipped on the right: ${tooltipBounds.right}/${tooltipBounds.width}`);
assert.ok(tooltipBounds.top >= 0, `Tooltip should not be clipped at the top: ${tooltipBounds.top}`);
assert.ok(tooltipBounds.bottom <= tooltipBounds.height, `Tooltip should not be clipped at the bottom: ${tooltipBounds.bottom}/${tooltipBounds.height}`);
const runTooltip = await hoverAndReadTooltip(
driver,
'[data-testid="run-active-tab"]',
/Start Scan|Run|Lookup/,
);
assert.ok(!runTooltip.error, `Global tooltip should render: ${runTooltip.error}`);
assert.equal(runTooltip.position, 'fixed', 'Tooltips should be fixed-layer, not clipped by tab overflow');
assert.ok(runTooltip.zIndex >= 1000, `Tooltip should sit above app overlays, got z-index ${runTooltip.zIndex}`);

const bounds = await hoverAndReadTooltip(driver, '.detail-actions button:last-child', /details pane/);
assert.ok(!bounds.error, `Tooltip bounds should be measurable: ${bounds.error}`);
assert.ok(bounds.left >= 0, `Tooltip should not be clipped on the left: ${bounds.left}`);
assert.ok(bounds.right <= bounds.viewportWidth, `Tooltip should not be clipped on the right: ${bounds.right}/${bounds.viewportWidth}`);
assert.ok(bounds.top >= 0, `Tooltip should not be clipped at the top: ${bounds.top}`);
assert.ok(bounds.bottom <= bounds.viewportHeight, `Tooltip should not be clipped at the bottom: ${bounds.bottom}/${bounds.viewportHeight}`);
}

async function assertInteractiveCursorTreatment(driver) {
Expand All @@ -122,26 +104,80 @@ async function assertInteractiveCursorTreatment(driver) {
assert.equal(state.disabledCursor, 'not-allowed', 'Disabled toolbar actions should advertise disabled affordance');
}

async function dispatchTooltipPointerOver(driver, selector) {
const dispatched = await driver.executeScript(
/**
* Hover a control and read its tooltip in ONE round trip.
*
* The three-step version of this -- dispatch, `waitForText`, then a separate
* `executeScript` to measure -- raced and lost. AppTooltip hides 40ms after a
* `pointerout` (see the close timer in AppTooltip.tsx), and each WebDriver
* round trip costs far more than 40ms, so anything producing a real pointerout
* between the wait and the measurement took the tooltip away. The wait passed,
* the measurement then found nothing, and the failure read as "Global tooltip
* should render" -- which sounds like the tooltip is broken rather than like a
* test that looked too late.
*
* Everything now happens inside the page: the hover is re-asserted on every
* animation frame until the text matches, so the hide timer is continually
* cancelled no matter what triggered it, and the measurement is taken in the
* same tick as the match. A real failure still reports, and now says what the
* tooltip actually held.
*/
async function hoverAndReadTooltip(driver, selector, pattern) {
return driver.executeAsyncScript(
`
const control = document.querySelector(arguments[0]);
if (!control) return false;
const rect = control.getBoundingClientRect();
const EventCtor = window.PointerEvent ?? MouseEvent;
control.dispatchEvent(new EventCtor('pointerover', {
bubbles: true,
cancelable: true,
composed: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
}));
if (typeof control.focus === 'function') control.focus({ preventScroll: true });
return true;
const selector = arguments[0];
const source = arguments[1];
const done = arguments[arguments.length - 1];
const wanted = new RegExp(source, 'i');
const control = document.querySelector(selector);
if (!control) return done({ error: 'no control matching ' + selector });

const hover = () => {
const rect = control.getBoundingClientRect();
const EventCtor = window.PointerEvent ?? MouseEvent;
control.dispatchEvent(new EventCtor('pointerover', {
bubbles: true,
cancelable: true,
composed: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
}));
if (typeof control.focus === 'function') control.focus({ preventScroll: true });
};

hover();
const deadline = Date.now() + 4000;
(function poll() {
const tooltip = document.querySelector('[data-testid="app-tooltip"]');
const text = tooltip ? tooltip.textContent || '' : '';
if (tooltip && wanted.test(text)) {
const style = getComputedStyle(tooltip);
const box = tooltip.getBoundingClientRect();
return done({
text,
position: style.position,
zIndex: Number(style.zIndex),
left: box.left,
right: box.right,
top: box.top,
bottom: box.bottom,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
});
}
if (Date.now() > deadline) {
return done({
error: 'tooltip never matched ' + wanted + '; last text was '
+ (tooltip ? JSON.stringify(text) : '(no tooltip element)'),
});
}
hover();
requestAnimationFrame(poll);
})();
`,
selector,
pattern.source,
);
assert.equal(dispatched, true, `Expected tooltip host ${selector} to exist`);
}

async function assertSuppressesNativeContextMenu(driver) {
Expand Down
Loading
Loading