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
11 changes: 11 additions & 0 deletions examples/order-flow/browser/test/e2e/order-flow-browser.spec.mts
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,17 @@ test.describe('order-flow in a web worker', () => {
await expect(page.locator('#log-level')).toHaveValue('debug');
});

/**
* The level filters what the server logs, so a host that waits for a logged
* line before connecting its diagram never connects above `info`. Nothing on
* this page does, and this keeps it so.
*/
test('the diagram loads with the level above info from startup', async ({ page }) => {
await page.goto('/?log=warn');
await expect(page.locator('[data-report="glsp-head"]')).toHaveAttribute('title', RENDERED_REPORT);
await expect(page.locator('#log').getByText('Log level info → warn (setting)', { exact: false })).toHaveCount(1);
});

test('the log-level picker reaches the server threshold over LSP configuration', async ({ page }) => {
await page.goto('/');
await expect(page.locator('[data-report="glsp-head"]')).toHaveAttribute('title', RENDERED_REPORT);
Expand Down
2 changes: 1 addition & 1 deletion examples/order-flow/theia-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ data and GLSP heads reached over their own sockets.
| Spec | What it observes |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `order-flow-properties.spec.mts` | a panel write reaching the shared Langium workspace — renaming a `.process` root raises a diagnostic on its `.layout`, which is cross-document and cross-grammar |
| `order-flow-diagram.spec.mts` | the diagram loading, with the ready-marker handshake read out of the **server's own log** rather than the UI, which cannot tell "never printed" from "never received" |
| `order-flow-diagram.spec.mts` | the diagram loading, and its loading overlay coming down |
| `order-flow-diagnostics.spec.mts` | the extension's _second_ channel to the data head answering at all |
| `order-flow-restart.spec.mts` | recovery after the language server is killed underneath the panel |

Expand Down
32 changes: 32 additions & 0 deletions examples/order-flow/theia-app/test/e2e/order-flow-app.mts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import { forwardBrowserConsole, serverLogFixtures, type ServerLogFixtures } from '@hydranium/core/testing/playwright';
import { expect, test as base, type Browser, type PlaywrightWorkerArgs } from '@playwright/test';
import { type TheiaApp, TheiaAppLoader, TheiaExplorerView, TheiaView, TheiaWorkspace } from '@theia/playwright';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import * as path from 'node:path';

/**
Expand Down Expand Up @@ -238,3 +240,33 @@ async function pickQuickInputItem(app: TheiaApp, filter: string, expected = filt
await expect(focused).toHaveText(expected);
await app.page.keyboard.press('Enter');
}

/**
* Matches the forked language server, and nothing else on the machine.
*
* RESOLVED, not written out. The VS Code extension launches the server with
* exactly this specifier, and `require.resolve` reports the realpath — so this
* is the absolute path that appears in the child's argv, derived the same way
* the launcher derives it. A hand-written fragment of that path instead rots
* silently in BOTH directions, and both have happened here: after the example
* moved one directory deeper the old fragment matched nothing, and because
* `pgrep` exits 1 on no match and {@link languageServerPids} maps that to an
* empty list, the spec stops testing restart recovery rather than failing. The
* mirror image is worse, because it looks like a pass: a fragment loose enough
* to match a leftover process from a previous layout makes the guard below
* succeed and `pkill` kill something the test never started.
*/
export const SERVER_PROCESS_PATTERN = createRequire(import.meta.url).resolve('@hydranium/example-order-flow-server/lib/main.js');

/** PIDs of the running language servers. Empty is a legitimate answer. */
export function languageServerPids(): string[] {
try {
return execFileSync('pgrep', ['-f', SERVER_PROCESS_PATTERN], { encoding: 'utf-8' })
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
} catch {
// `pgrep` exits 1 when nothing matches.
return [];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/********************************************************************************
* Copyright (c) 2026 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the MIT License which is available in the project root.
*
* SPDX-License-Identifier: MIT
********************************************************************************/

/**
* What an open diagram does when the language server is killed underneath it.
*
* The GLSP server lives in the language server's process, so the kill takes the
* diagram's server with it. The client contribution starts a fresh client over
* a fresh channel, the backend forwarder finds the replacement's new port, and
* the diagram reopens in its tab as a fresh widget.
*
* A diagram that did not recover still shows its old nodes, so they prove
* nothing on their own. The loading overlay coming back after the kill says the
* tab reopened, a GLSP connection in the server log after the kill says it
* reached the replacement server, and an edit applied exactly once says the
* diagram is editable again rather than only drawn.
*
* **Runs alone, with one worker**, for the reason the data-head restart spec
* gives: it kills a process by command-line pattern.
*/

import { expect } from '@playwright/test';
import { resolveServerLogPath } from '@hydranium/core/testing/playwright';
import { type TheiaApp, TheiaExplorerView } from '@theia/playwright';
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { languageServerPids, loadOrderFlowApp, SERVER_PROCESS_PATTERN, test } from './order-flow-app.mjs';

/** Class contract published by `@hydranium/glsp-client-theia`'s diagram widget. */
const LOADING_OVERLAY_CLASS = 'hydranium-diagram-loading';

/** Logged by the GLSP server when a client connects to its socket. */
const GLSP_CONNECTION_LINE = 'Starting GLSP server connection';

test.describe.serial('Order-flow diagram across a language-server restart', { tag: '@restart' }, () => {
let app: TheiaApp;

test.beforeAll(async ({ playwright, browser }) => {
app = await loadOrderFlowApp({ playwright, browser });
});

test.afterAll(async () => {
await app.page.close();
});

test('the diagram loads before the restart', async () => {
const explorer = await app.openView(TheiaExplorerView);
await explorer.waitForVisibleFileNodes();
await explorer.clickContextMenuItem('orders/fulfillment.process', ['Open']);
await expect(app.page.locator('.sprotty').getByText('Pay', { exact: false }).first()).toBeVisible({ timeout: 30_000 });
await expect(app.page.locator(`.${LOADING_OVERLAY_CLASS}`)).toHaveCount(0);
expect(languageServerPids().length, 'no language server to restart').toBeGreaterThan(0);
});

test('the diagram loads again on the replacement server', async () => {
test.setTimeout(120_000);
// Watched from before the kill: the reload's overlay can come and go
// between two polls.
await app.page.evaluate(overlayClass => {
const globals = window as unknown as { __hydraniumReloadSeen?: boolean };
globals.__hydraniumReloadSeen = false;
new MutationObserver(records => {
for (const record of records) {
for (const node of Array.from(record.addedNodes)) {
if (node instanceof HTMLElement && node.classList.contains(overlayClass)) {
globals.__hydraniumReloadSeen = true;
}
}
}
}).observe(document.body, { childList: true, subtree: true });
}, LOADING_OVERLAY_CLASS);

const before = languageServerPids();
execFileSync('pkill', ['-f', SERVER_PROCESS_PATTERN]);
await expect
.poll(() => languageServerPids().filter(pid => !before.includes(pid)).length, {
message: 'the language server did not restart under a new pid',
timeout: 60_000
})
.toBeGreaterThan(0);

await expect
.poll(() => app.page.evaluate(() => (window as unknown as { __hydraniumReloadSeen?: boolean }).__hydraniumReloadSeen === true), {
message: 'the diagram never reloaded after the kill',
timeout: 60_000
})
.toBe(true);
await expect(app.page.locator(`.${LOADING_OVERLAY_CLASS}`)).toHaveCount(0, { timeout: 60_000 });
await expect(app.page.locator('.sprotty').getByText('Pay', { exact: false }).first()).toBeVisible();

const log = resolveServerLogPath(app.workspace.path);
expect(log, 'no server log to read').toBeDefined();
const text = readFileSync(log!, 'utf-8');
const sinceTheKill = text.slice(text.lastIndexOf('===== START: the diagram loads again'));
expect(sinceTheKill, 'no client reached the replacement GLSP server').toContain(GLSP_CONNECTION_LINE);
});

/** A diagram loaded again in the same container registers GLSP's model
* source handlers twice, and then sends every edit twice: a second task. */
test('an edit after the restart applies once', async () => {
const diagram = app.page.locator('#theia-main-content-panel .sprotty').first();
// A second task would be proposed under the next free name that starts with `NewTask`.
const newTasks = diagram.locator('svg.sprotty-graph').getByText(/^NewTask/);
const taskTool = app.page.locator('#theia-main-content-panel .tool-button', { hasText: 'Task' });
const box = await diagram.boundingBox();
expect(box, 'the diagram has no box to click in').not.toBeNull();
// Retried because a model update right after the load disarms the
// palette's tool; a retry follows only a click that created nothing.
await expect(async () => {
await taskTool.click();
await expect(taskTool).toHaveClass(/clicked/);
// Low and left of centre, where the fitted process leaves empty canvas.
await diagram.click({ position: { x: box!.width * 0.4, y: box!.height * 0.85 } });
await expect(newTasks).not.toHaveCount(0, { timeout: 5_000 });
}).toPass({ timeout: 30_000 });
// The create opens the new node's label editor, which would keep the keyboard.
await app.page.keyboard.press('Escape');

await expect(newTasks).toHaveCount(1);
// A second application would land right after the first.
await app.page.waitForTimeout(2_000);
await expect(newTasks).toHaveCount(1);
});
});
35 changes: 4 additions & 31 deletions examples/order-flow/theia-app/test/e2e/order-flow-diagram.spec.mts
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,15 @@
* the data head, the diagram to the GLSP head — and a diagram that never
* finishes loading would otherwise take the panel's passing assertions down with
* it, or worse, be masked by them.
*
* The ready-marker test reads the LANGUAGE SERVER's own log rather than the UI,
* and that is the point of it: `HydraniumGlspClientContribution.waitForBackendConnected`
* gates the client on a server-printed marker arriving in a Theia Output
* channel, so when the diagram hangs there are two very different causes — the
* server never printed the marker, or it printed it and the Theia side never saw
* it. The UI cannot tell them apart. The captured log can, and reading a file
* perturbs nothing, whereas opening the Output view to look would itself change
* the state under test.
*/

import { expect } from '@playwright/test';
import { resolveServerLogPath } from '@hydranium/core/testing/playwright';
import { type TheiaApp, TheiaExplorerView } from '@theia/playwright';
import { readFileSync } from 'node:fs';
import { loadOrderFlowApp, test } from './order-flow-app.mjs';

/** Class contract published by `@hydranium/glsp-client-theia`'s diagram widget. */
const LOADING_OVERLAY_CLASS = 'hydranium-diagram-loading';

/** Must match `ORDER_FLOW_GLSP_READY_MARKER` in `order-flow-theia`. */
const GLSP_READY_MARKER = 'Starting GLSP server connection';

/**
* Watch for the loading overlay from *before* the diagram is opened.
*
Expand Down Expand Up @@ -112,22 +98,9 @@ test.describe.serial('Order-flow diagram in Theia', () => {
await expect(app.page.locator(`.${LOADING_OVERLAY_CLASS}`)).toHaveCount(0);
});

test('the GLSP ready marker is emitted on the language server connection', async () => {
// Diagnostic, and ordered last on purpose: it runs after the diagram test
// so it reports on that attempt, and it reads a file rather than the UI so
// it cannot itself resolve the very wait it is investigating.
//
// The marker is `JsonRpcGLSPServerLauncher.configureClientConnection`'s
// log line, i.e. it is printed when a client CONNECTS to the GLSP socket,
// not when the socket starts listening. It reaches this log through
// `GlspClientLogger`, the same sink that carries it to the Theia Output
// channel the client contribution tails — so its presence here means the
// server side of that handshake happened and the frontend's failure to see
// it is a delivery problem, and its absence means no client ever reached
// the socket.
const logPath = resolveServerLogPath(app.workspace.path);
expect(logPath, 'server-log capture is off; run with HYDRANIUM_SERVER_LOG_DIR set').toBeDefined();
const log = readFileSync(logPath!, 'utf-8');
expect(log).toContain(GLSP_READY_MARKER);
test("GLSP's status overlay is on the page, where a server status can show", async () => {
// It is inserted into the diagram's base div before sprotty's first render
// replaces that div; left there, it is never on the page at all.
await expect(app.page.locator('.sprotty-status')).toHaveCount(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/********************************************************************************
* Copyright (c) 2026 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the MIT License which is available in the project root.
*
* SPDX-License-Identifier: MIT
********************************************************************************/

/**
* The order-flow diagram with the server log level above `info`.
*
* The level filters what the server writes to the Output channel, so a client
* gate that waits for a line in that channel never opens at `warn`: the diagram
* stays at its loading overlay and nothing reports a failure. A separate spec
* because the level is a workspace setting, read once at startup, and every
* other spec runs at the default.
*/

import { expect } from '@playwright/test';
import { type TheiaApp, TheiaExplorerView } from '@theia/playwright';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { loadOrderFlowApp, test } from './order-flow-app.mjs';

/** A workspace overlay that raises the server log level to `warn`. */
function warnLevelOverlay(): string {
const overlay = mkdtempSync(path.join(tmpdir(), 'order-flow-log-warn-'));
mkdirSync(path.join(overlay, '.theia'));
writeFileSync(path.join(overlay, '.theia', 'settings.json'), JSON.stringify({ 'order-flow.log.level': 'warn' }));
return overlay;
}

test.describe('Order-flow diagram with the server log level at warn', () => {
let app: TheiaApp;

test.beforeAll(async ({ playwright, browser }) => {
app = await loadOrderFlowApp({ playwright, browser }, [warnLevelOverlay()]);
});

test.afterAll(async () => {
await app.page.close();
});

test('opens fulfillment.process as a GLSP diagram', async () => {
const explorer = await app.openView(TheiaExplorerView);
await explorer.waitForVisibleFileNodes();
await explorer.clickContextMenuItem('orders/fulfillment.process', ['Open']);

await expect(app.page.locator('.sprotty').getByText('Pay', { exact: false }).first()).toBeVisible({ timeout: 30_000 });
await expect(app.page.locator('.sprotty').getByText('Ship', { exact: false }).first()).toBeVisible();
});
});
46 changes: 13 additions & 33 deletions examples/order-flow/theia-app/test/e2e/order-flow-restart.spec.mts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,18 @@
import { expect } from '@playwright/test';
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { resolveServerLogPath } from '@hydranium/core/testing/playwright';
import type { TheiaApp } from '@theia/playwright';
import { loadOrderFlowApp, openPropertiesPanel, PROPERTIES_PANEL as PANEL, runCommand, selectFile, test } from './order-flow-app.mjs';
import {
languageServerPids,
loadOrderFlowApp,
openPropertiesPanel,
PROPERTIES_PANEL as PANEL,
runCommand,
SERVER_PROCESS_PATTERN,
selectFile,
test
} from './order-flow-app.mjs';

/**
* Two diagnostics commands as the palette lists them (`category: title`), from
Expand All @@ -60,36 +68,6 @@ function diagnosticsToast(app: TheiaApp, summary: string): ReturnType<TheiaApp['
return app.page.locator('.theia-notification-message').filter({ hasText: summary }).first();
}

/**
* Matches the forked language server, and nothing else on the machine.
*
* RESOLVED, not written out. The VS Code extension launches the server with
* exactly this specifier, and `require.resolve` reports the realpath — so this
* is the absolute path that appears in the child's argv, derived the same way
* the launcher derives it. A hand-written fragment of that path instead rots
* silently in BOTH directions, and both have happened here: after the example
* moved one directory deeper the old fragment matched nothing, and because
* `pgrep` exits 1 on no match and {@link languageServerPids} maps that to an
* empty list, the spec stops testing restart recovery rather than failing. The
* mirror image is worse, because it looks like a pass: a fragment loose enough
* to match a leftover process from a previous layout makes the guard below
* succeed and `pkill` kill something the test never started.
*/
const SERVER_PROCESS_PATTERN = createRequire(import.meta.url).resolve('@hydranium/example-order-flow-server/lib/main.js');

/** PIDs of the running language servers. Empty is a legitimate answer. */
function languageServerPids(): string[] {
try {
return execFileSync('pgrep', ['-f', SERVER_PROCESS_PATTERN], { encoding: 'utf-8' })
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
} catch {
// `pgrep` exits 1 when nothing matches.
return [];
}
}

/**
* Re-open the panel on `filePath`, forcing a real load.
*
Expand Down Expand Up @@ -217,8 +195,10 @@ test.describe.serial('Order-flow data connection across a language-server restar
};
await expect
.poll(sinceTheKill, { message: 'the panel never registered again on the new server', timeout: 90_000 })
// The diagram the explorer opened recovers too, and whichever of the
// two reaches the document first opens it; the other attaches.
.toMatch(
/Session started: order-flow-theia-properties#[\s\S]*fulfillment\.process\] Open document: .* by order-flow-theia-properties#/
/Session started: order-flow-theia-properties#[\s\S]*fulfillment\.process\] (?:Open document: .* by|Attach client:) order-flow-theia-properties#/
);
});

Expand Down
Loading
Loading