Skip to content

Commit 4328c5d

Browse files
authored
feat: add operational sidebar control plane (#921)
* docs: specify operational control plane * feat: add cockpit runtime bridge protocol * feat: add embedded runtime readiness lifecycle * feat: report readiness from cockpit Angular runtimes * feat: model cockpit runtime operations * fix: accept credentials in cockpit runtime URLs * fix: preserve cockpit runtime transition identity * feat: connect cockpit runtime controller * fix: isolate cockpit runtime route context * fix: preserve committed runtime callbacks * feat: add cockpit runtime and activity controls * fix: contain control-plane menu escape * fix: harden operational control interactions * fix: contain operational menu interactions * feat: integrate cockpit operational control plane * fix: harden cockpit control-plane accessibility * fix: preserve cockpit focus and request origin * fix: secure cockpit redirect and navigation intent * feat: add docs runtime control handoff * fix: cancel docs mobile intent at desktop breakpoint * style: polish operational control-plane states * fix: resolve control-plane state review findings * fix: fit runtime menus and localize activity time * fix: contain runtime menu on coarse pointers * ci: deploy instrumented runtimes before cockpit * test: harden production deployment smoke * test: cover operational control-plane flows * fix: keep cockpit playwright output ignored * fix: instrument newly added cockpit runtimes * fix(website): normalize neutral docs handoff identity * fix(ci): keep runtime bridge guard on node test
1 parent d99daae commit 4328c5d

122 files changed

Lines changed: 11777 additions & 595 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ jobs:
4949
# classifier walk.
5050
- run: npm ci
5151
- name: Test CI scope classifier
52-
run: node --test scripts/ci-scope.spec.mjs scripts/cockpit-matrix.spec.mjs scripts/cockpit-ports.spec.mjs
52+
run: node --test scripts/ci-scope.spec.mjs scripts/cockpit-matrix.spec.mjs scripts/cockpit-ports.spec.mjs scripts/cockpit-runtime-bridge-coverage.spec.mjs
5353
- name: Detect changed CI surfaces
5454
id: scope
5555
run: |
@@ -865,6 +865,19 @@ jobs:
865865
run: npx nx e2e website --skip-nx-cache
866866
env:
867867
BASE_URL: https://threadplane.ai
868+
- name: Build and assemble Angular examples
869+
if: steps.freshness.outputs.stale != 'true' && steps.examples_changed.outputs.changed == 'true'
870+
run: npx tsx scripts/assemble-examples.ts
871+
- name: Deploy Angular examples to Vercel (production)
872+
if: steps.freshness.outputs.stale != 'true' && steps.examples_changed.outputs.changed == 'true'
873+
working-directory: deploy/examples
874+
run: |
875+
mkdir -p .vercel
876+
cat > .vercel/project.json <<EOF
877+
{"projectId":"${{ secrets.VERCEL_EXAMPLES_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"threadplane-examples"}
878+
EOF
879+
npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
880+
npx vercel deploy --prebuilt --prod --yes --token=${{ secrets.VERCEL_TOKEN }}
868881
- name: Prepare cockpit Vercel project
869882
if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true'
870883
run: |
@@ -886,20 +899,6 @@ jobs:
886899
run: |
887900
npx tsx apps/cockpit/scripts/deploy-smoke.ts --url https://cockpit.threadplane.ai --retries 20 --retry-delay-ms 5000
888901
889-
- name: Build and assemble Angular examples
890-
if: steps.freshness.outputs.stale != 'true' && steps.examples_changed.outputs.changed == 'true'
891-
run: npx tsx scripts/assemble-examples.ts
892-
- name: Deploy Angular examples to Vercel (production)
893-
if: steps.freshness.outputs.stale != 'true' && steps.examples_changed.outputs.changed == 'true'
894-
working-directory: deploy/examples
895-
run: |
896-
mkdir -p .vercel
897-
cat > .vercel/project.json <<EOF
898-
{"projectId":"${{ secrets.VERCEL_EXAMPLES_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"threadplane-examples"}
899-
EOF
900-
npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
901-
npx vercel deploy --prebuilt --prod --yes --token=${{ secrets.VERCEL_TOKEN }}
902-
903902
# Advance only when the whole job succeeded and actually promoted. On a
904903
# stale run, a failure, or a partial deploy the marker stays put, so the
905904
# next run's range still covers whatever did not ship. Never `always()`:

apps/cockpit/cockpit-e2e-wiring.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,45 @@ function activeCockpitE2eWiring(): E2eWiring[] {
114114
}
115115

116116
describe('cockpit e2e wiring', () => {
117+
it('keeps the representative production smoke deterministic and scoped to Recheck', () => {
118+
const smoke = readRepoFile('apps/cockpit/e2e/production-smoke.spec.ts');
119+
const start = smoke.indexOf(
120+
"test('representative runtime reports Ready after Recheck and records Activity'"
121+
);
122+
const end = smoke.indexOf("test('favicon resolves after redirects'", start);
123+
const representative = smoke.slice(start, end);
124+
125+
expect(start).toBeGreaterThan(-1);
126+
expect(end).toBeGreaterThan(start);
127+
expect(representative).toContain('test.setTimeout(60_000)');
128+
expect(representative).toContain("getByText('Ready', { exact: true })");
129+
expect(representative).toContain(
130+
"getByRole('button', { name: 'Recheck' })"
131+
);
132+
expect(representative).toContain('await expect(checkEvents).toHaveCount(1)');
133+
expect(representative).toContain('await expect(readyEvents).toHaveCount(1)');
134+
expect(representative).toContain('await expect(checkEvents).toHaveCount(2)');
135+
expect(representative).toContain('await expect(readyEvents).toHaveCount(2)');
136+
expect(representative).not.toContain(
137+
"getByRole('button', { name: 'Reload runtime' })"
138+
);
139+
});
140+
141+
it('keeps the production favicon redirect covered', () => {
142+
const smoke = readRepoFile('apps/cockpit/e2e/production-smoke.spec.ts');
143+
const start = smoke.indexOf("test('favicon resolves after redirects'");
144+
const end = smoke.indexOf(
145+
"test.describe('Production: canonical demo sends runtime telemetry'",
146+
start
147+
);
148+
const favicon = smoke.slice(start, end);
149+
150+
expect(start).toBeGreaterThan(-1);
151+
expect(end).toBeGreaterThan(start);
152+
expect(favicon).toContain('request.get(`${COCKPIT_URL}/favicon.ico`)');
153+
expect(favicon).toContain('expect(response.status()).toBeLessThan(400)');
154+
});
155+
117156
it('does not leave cockpit e2e spec files outside Nx e2e targets', () => {
118157
const projects = new Map(
119158
listProjectJsonFiles(join(repoRoot, 'cockpit')).map((projectJsonPath) => {
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { expect, test, type Page } from '@playwright/test';
2+
3+
const route = '/langgraph/core-capabilities/streaming/overview/python';
4+
5+
declare global {
6+
interface Window {
7+
__cockpitAboutBlankMounted?: boolean;
8+
__cockpitRuntimePhases?: string[];
9+
}
10+
}
11+
12+
async function installRuntimeObservation(page: Page) {
13+
await page.addInitScript(() => {
14+
window.__cockpitAboutBlankMounted = false;
15+
window.__cockpitRuntimePhases = [];
16+
17+
const inspect = () => {
18+
for (const frame of document.querySelectorAll('iframe')) {
19+
if (frame.getAttribute('src') === 'about:blank') {
20+
window.__cockpitAboutBlankMounted = true;
21+
}
22+
}
23+
for (const status of document.querySelectorAll('[data-runtime-phase]')) {
24+
const phase = status.getAttribute('data-runtime-phase');
25+
if (phase && !window.__cockpitRuntimePhases?.includes(phase)) {
26+
window.__cockpitRuntimePhases?.push(phase);
27+
}
28+
}
29+
};
30+
31+
new MutationObserver(inspect).observe(document, {
32+
attributes: true,
33+
attributeFilter: ['src', 'data-runtime-phase'],
34+
childList: true,
35+
subtree: true,
36+
});
37+
document.addEventListener('DOMContentLoaded', inspect, { once: true });
38+
});
39+
}
40+
41+
async function expectNoHorizontalOverflow(page: Page, label: string) {
42+
const overflow = await page.evaluate(
43+
() =>
44+
document.documentElement.scrollWidth -
45+
document.documentElement.clientWidth
46+
);
47+
expect(overflow, label).toBeLessThanOrEqual(1);
48+
}
49+
50+
test.describe('Cockpit operational control plane', () => {
51+
test('completes the real Angular handshake without blank or unresponsive states', async ({
52+
page,
53+
}) => {
54+
await installRuntimeObservation(page);
55+
await page.goto(route);
56+
57+
await expect(page.getByText('Ready', { exact: true })).toBeVisible();
58+
expect(await page.evaluate(() => window.__cockpitAboutBlankMounted)).toBe(
59+
false
60+
);
61+
expect(
62+
await page.evaluate(() => window.__cockpitRuntimePhases)
63+
).not.toContain('unresponsive');
64+
65+
await page.getByRole('button', { name: 'Activity' }).click();
66+
await expect(
67+
page.locator('[data-activity-kind="runtime_check_requested"]')
68+
).toHaveCount(1);
69+
await expect(
70+
page.locator('[data-activity-kind="runtime_ready"]')
71+
).toHaveCount(1);
72+
await page.getByRole('button', { name: 'Close Activity' }).click();
73+
74+
await page.getByRole('button', { name: 'Recheck' }).click();
75+
await expect(page.getByText('Ready', { exact: true })).toBeVisible();
76+
await page.getByRole('button', { name: 'Activity' }).click();
77+
await expect(
78+
page.locator('[data-activity-kind="runtime_check_requested"]')
79+
).toHaveCount(2);
80+
await expect(
81+
page.locator('[data-activity-kind="runtime_ready"]')
82+
).toHaveCount(2);
83+
expect(
84+
await page.evaluate(() => window.__cockpitRuntimePhases)
85+
).not.toContain('unresponsive');
86+
});
87+
88+
for (const viewport of [
89+
{ width: 1440, height: 900, surface: 'desktop' },
90+
{ width: 768, height: 900, surface: 'tablet' },
91+
{ width: 390, height: 844, surface: 'mobile' },
92+
{ width: 320, height: 844, surface: 'compact mobile' },
93+
] as const) {
94+
test(`${viewport.surface} keeps operational controls reachable`, async ({
95+
page,
96+
}) => {
97+
await page.setViewportSize(viewport);
98+
await page.goto(route);
99+
await expectNoHorizontalOverflow(page, `Cockpit at ${viewport.width}px`);
100+
101+
const desktopNavigation = page.locator(
102+
'[data-cockpit-desktop-navigation]'
103+
);
104+
const mobileTrigger = page.getByRole('button', {
105+
name: 'Open navigation',
106+
});
107+
if (viewport.width >= 768) {
108+
await expect(desktopNavigation).toBeVisible();
109+
await expect(mobileTrigger).toBeHidden();
110+
await expect(
111+
page.getByRole('button', { name: 'Runtime', exact: true })
112+
).toBeVisible();
113+
await page.getByRole('button', { name: 'Activity' }).click();
114+
await expect(
115+
page.getByRole('heading', { name: 'Activity' })
116+
).toBeVisible();
117+
} else {
118+
await expect(desktopNavigation).toBeHidden();
119+
await expect(mobileTrigger).toBeVisible();
120+
const triggerBox = await mobileTrigger.boundingBox();
121+
expect(triggerBox?.width).toBeGreaterThanOrEqual(44);
122+
expect(triggerBox?.height).toBeGreaterThanOrEqual(44);
123+
124+
await mobileTrigger.click();
125+
const dialog = page.getByRole('dialog', {
126+
name: 'Cockpit control plane',
127+
});
128+
await expect(dialog).toBeVisible();
129+
await expect(page.locator('[data-cockpit-workspace]')).toHaveAttribute(
130+
'inert',
131+
''
132+
);
133+
await expect(
134+
dialog.getByRole('button', { name: 'Runtime', exact: true })
135+
).toBeVisible();
136+
await dialog.getByRole('button', { name: 'Activity' }).click();
137+
await expect(
138+
dialog.getByRole('heading', { name: 'Activity' })
139+
).toBeVisible();
140+
await dialog.getByRole('button', { name: 'Close Activity' }).click();
141+
await expect(
142+
dialog.getByRole('button', { name: 'Runtime', exact: true })
143+
).toBeVisible();
144+
145+
const close = dialog.getByRole('button', { name: 'Close navigation' });
146+
const closeBox = await close.boundingBox();
147+
expect(closeBox?.width).toBeGreaterThanOrEqual(44);
148+
expect(closeBox?.height).toBeGreaterThanOrEqual(44);
149+
await page.keyboard.press('Escape');
150+
await expect(dialog).toBeHidden();
151+
await expect(mobileTrigger).toBeFocused();
152+
}
153+
});
154+
}
155+
156+
test('forced colors preserve control boundaries and keyboard focus', async ({
157+
page,
158+
}) => {
159+
await page.emulateMedia({ forcedColors: 'active' });
160+
await page.goto(route);
161+
162+
const runtime = page.getByRole('button', { name: 'Runtime', exact: true });
163+
await runtime.focus();
164+
const styles = await runtime.evaluate((element) => {
165+
const style = getComputedStyle(element);
166+
return {
167+
borderWidth: style.borderTopWidth,
168+
outlineStyle: style.outlineStyle,
169+
outlineWidth: style.outlineWidth,
170+
};
171+
});
172+
expect(Number.parseFloat(styles.borderWidth)).toBeGreaterThan(0);
173+
expect(styles.outlineStyle).not.toBe('none');
174+
expect(Number.parseFloat(styles.outlineWidth)).toBeGreaterThan(0);
175+
});
176+
177+
test('reduced motion disables loader and drawer animation', async ({
178+
page,
179+
}) => {
180+
await page.emulateMedia({ reducedMotion: 'reduce' });
181+
await page.route('http://localhost:4300/**', (request) => request.abort());
182+
await page.setViewportSize({ width: 390, height: 844 });
183+
await page.goto(route);
184+
await page.getByRole('button', { name: 'Open navigation' }).click();
185+
186+
const dialog = page.getByRole('dialog', { name: 'Cockpit control plane' });
187+
const loader = dialog.locator('.cockpit-runtime-status-loader');
188+
await expect(loader).toBeVisible();
189+
expect(
190+
await loader.evaluate(
191+
(element) => getComputedStyle(element).animationName
192+
)
193+
).toBe('none');
194+
195+
const panel = page.locator('.cockpit-mobile-control-plane-panel');
196+
await expect(panel).toBeVisible();
197+
const motion = await panel.evaluate((element) => {
198+
const style = getComputedStyle(element);
199+
return {
200+
animationName: style.animationName,
201+
transitionDuration: style.transitionDuration,
202+
};
203+
});
204+
expect(motion.animationName).toBe('none');
205+
expect(motion.transitionDuration).toBe('0s');
206+
});
207+
});

apps/cockpit/e2e/production-smoke.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,44 @@ test.describe('Production: cockpit shell loads', () => {
139139
);
140140
expect(overviewLinks).toHaveLength(0);
141141
});
142+
143+
test('representative runtime reports Ready after Recheck and records Activity', async ({
144+
page,
145+
}) => {
146+
test.setTimeout(60_000);
147+
const runtimeRoute = `${COCKPIT_URL}/langgraph/core-capabilities/streaming/overview/python`;
148+
await page.goto(runtimeRoute, { timeout: 15_000 });
149+
150+
const ready = page.getByText('Ready', { exact: true });
151+
await expect(ready).toBeVisible({ timeout: 15_000 });
152+
153+
await page.getByRole('button', { name: 'Activity' }).click();
154+
const checkEvents = page.locator(
155+
'[data-activity-kind="runtime_check_requested"]'
156+
);
157+
const readyEvents = page.locator('[data-activity-kind="runtime_ready"]');
158+
await expect(checkEvents).toHaveCount(1);
159+
await expect(readyEvents).toHaveCount(1);
160+
await page.getByRole('button', { name: 'Close Activity' }).click();
161+
162+
const checkedAt = page.locator('[data-runtime-checked-at]');
163+
const checkedAtBefore = await checkedAt.textContent();
164+
await page.getByRole('button', { name: 'Recheck' }).click();
165+
await expect
166+
.poll(() => checkedAt.textContent(), { timeout: 15_000 })
167+
.not.toBe(checkedAtBefore);
168+
await expect(ready).toBeVisible({ timeout: 15_000 });
169+
170+
await page.getByRole('button', { name: 'Activity' }).click();
171+
await expect(checkEvents).toHaveCount(2);
172+
await expect(readyEvents).toHaveCount(2);
173+
});
174+
175+
test('favicon resolves after redirects', async ({ request }) => {
176+
const response = await request.get(`${COCKPIT_URL}/favicon.ico`);
177+
178+
expect(response.status()).toBeLessThan(400);
179+
});
142180
});
143181

144182
test.describe('Production: canonical demo sends runtime telemetry', () => {

apps/cockpit/playwright.config.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
3+
const cockpitHost = '127.0.0.1';
4+
const cockpitPort = '4201';
5+
const cockpitURL = `http://${cockpitHost}:${cockpitPort}`;
6+
const runtimeURL = 'http://localhost:4300';
7+
const reuseExistingServer =
8+
process.env['PLAYWRIGHT_REUSE_EXISTING_SERVER'] === 'true';
9+
export default defineConfig({
10+
testDir: './e2e',
11+
testMatch: 'control-plane.spec.ts',
12+
outputDir: '../../test-results/cockpit',
13+
fullyParallel: false,
14+
retries: process.env['CI'] ? 2 : 0,
15+
use: {
16+
baseURL: cockpitURL,
17+
},
18+
projects: [
19+
{
20+
name: 'chromium',
21+
use: { ...devices['Desktop Chrome'] },
22+
},
23+
],
24+
webServer: [
25+
{
26+
command:
27+
"NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL='' npx nx serve cockpit --port 4201 --hostname 127.0.0.1",
28+
cwd: '../..',
29+
url: cockpitURL,
30+
reuseExistingServer,
31+
},
32+
{
33+
command:
34+
'npx nx run cockpit-langgraph-streaming-angular:serve:cockpit --port 4300',
35+
cwd: '../..',
36+
url: runtimeURL,
37+
reuseExistingServer,
38+
},
39+
],
40+
});

0 commit comments

Comments
 (0)