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
20 changes: 20 additions & 0 deletions .ai/architecture.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,26 @@ components:
publish-release.sh: Publish to VS Code marketplace
release-and-publish.sh: Combined release and publish
setup-github-token.sh: GitHub token configuration
demo_gifs:
location: scripts/demo-gifs/
purpose: Maintainer tool that records the README demo GIFs by driving a real VS Code through Playwright over CDP
packaged: false (scripts/** in .vscodeignore; own package.json, out/ and node_modules/ gitignored)
files:
record.mjs: Entry point. Resets the work dir, packages + installs the VSIX, warms the cache, records scenes
lib/config.mjs: Paths, viewport (1280x800 at 2x), GIF size (1100 wide, 12 fps), env overrides; refuses a
GCH_DEMO_DIR not named gch-demo* or containing the home folder or repo, since it is wiped each run
lib/workspace.mjs: Throwaway profile, demo workspace (my-pipelines), vsce package + install
lib/vscode.mjs: Launch/stop VS Code, Command Palette, scene reset, webview frame lookup
lib/input.mjs: Drawn cursor, eased mouse moves, typing, wheel scrolling
lib/recorder.mjs: CDP screencast capture, ffmpeg GIF encode, per-second review sheet
scenes/: One module per GIF (browse, complete, hover, validate, versions)
fixtures/: Profile settings.json (component sources, quiet editor) and templates/deploy.yml for local includes
how_it_works:
- VS Code starts with --remote-debugging-port=0; the port is read from <profile>/DevToolsActivePort and
Playwright attaches with chromium.connectOverCDP, so a stray instance can't be picked up by mistake
- Emulation.setDeviceMetricsOverride fixes the viewport so output size doesn't depend on the screen
- The OS pointer isn't captured, so a cursor element is drawn in the top document and moved with page.mouse
- Page.startScreencast frames only arrive on change; an ffmpeg concat list holds each frame until the next
interfaces:
Component:
location: src/types/git-component.ts
Expand Down
1 change: 1 addition & 0 deletions .ai/context.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ structure:
types: src/types/
utils: src/utils/
scripts: scripts/
demo_gifs: scripts/demo-gifs/
context_modules:
architecture: architecture.yaml
workflows: workflows.yaml
Expand Down
32 changes: 32 additions & 0 deletions .ai/decisions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,38 @@ decisions:
- .mocharc.cjs
- package.json (test scripts)
title: Mocha Over Jest
DEMO_GIFS_RECORDED_BY_SCRIPT:
date: '2026-10'
status: accepted
context: README/Marketplace GIFs were hand-recorded, went stale as the UI changed, and risked showing the
maintainer's own projects and paths
decision: Record them with scripts/demo-gifs (Playwright driving a packaged VSIX in a throwaway profile) and
host the output as GitHub user-attachments linked from README.md
rationale:
- Re-recording after a UI change is one command, and every take looks the same
- Fresh profile, neutral my-pipelines folder and public gitlab.com components keep personal details out
- Installing the packaged VSIX keeps "[Extension Development Host]" out of the title bar
- user-attachments URLs are public for a public repo and render on the Marketplace; committing GIFs would
grow the repo by ~4 MB per re-record
consequences:
positive:
- Demos can be refreshed with every UI change
- Review sheets make the privacy check quick
negative:
- macOS + local VS Code only; not run in CI
- user-attachments links stop rendering publicly if the repo goes private, and GitHub doesn't promise
they last forever
alternatives_considered:
- name: manual screen recording
rejected_because: Inconsistent, slow to redo, easy to leak personal details
- name: commit GIFs under images/
rejected_because: Repo growth on every re-record
- name: extensionDevelopmentPath instead of a VSIX
rejected_because: Title bar shows [Extension Development Host]
references:
- scripts/demo-gifs/record.mjs
- .ai/workflows.yaml (record_demo_gifs)
title: Demo GIFs Recorded By Script
future_decisions:
- question: Should we support other CI/CD platforms (GitHub Actions, CircleCI)?
status: under_consideration
Expand Down
20 changes: 20 additions & 0 deletions .ai/errors.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,26 @@ errors:
- Re-enable v10 once @release-it/conventional-changelog adopts the render-function
stack (conventional-changelog@8 / writer@9)
reference: .github/dependabot.yml + package.json; see huntridge-labs/argus#318
demo_gif_recording_fails:
symptom: npm run record in scripts/demo-gifs fails or produces a wrong-looking GIF
causes:
- cause: 'listen EINVAL on .../1.13-main.sock or "IPC handle is longer than 103 chars"'
solution: Point GCH_DEMO_DIR at a short folder named gch-demo*, e.g. /tmp/gch-demo2 (default /tmp/gch-demo).
Other names are refused because the folder is wiped on every run
- cause: 'mach_port_rendezvous ... Permission denied, or "Starting inspector failed: operation not permitted"'
solution: Run outside the command sandbox; VS Code can't start inside it
- cause: VS Code never wrote DevToolsActivePort, or a stray instance from a crashed run holds the profile
solution: pkill -9 -f "user-data-dir=${GCH_DEMO_DIR:-/tmp/gch-demo}/profile" and run again
- cause: Command Palette did not highlight a command
solution: The command title changed in VS Code; update the name in the scene or lib/vscode.mjs
- cause: An empty "Drag a view here" pane or a side bar shows up
solution: prepareScene hides side bars by checking visibility; check the .part.* selectors still match
- cause: Typing lands at the wrong indent or Enter accepts a suggestion
solution: Scenes rely on fixtures/settings.json (wordBasedSuggestions off) and VS Code's YAML auto-indent;
re-check those after a VS Code update
- cause: A webview element is never found
solution: Webviews are out-of-process frames that only attach while the script is connected; open the
panel inside the same run and use webviewFrame(page, selector)
debugging:
enable_debug_logs:
steps:
Expand Down
4 changes: 4 additions & 0 deletions .ai/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ keys:
- cache
- component
- constants
- demo_gifs
- errors
- extension
- parsers
Expand All @@ -35,6 +36,7 @@ keys:
- build_and_package
- debug_extension
- fix_failing_test
- record_demo_gifs
- release
- run_tests
- setup_dev_environment
Expand All @@ -43,6 +45,7 @@ keys:
- BATCH_API_REQUESTS
- CACHE_COMPONENTS
- CENTRALIZED_ERROR_HANDLING
- DEMO_GIFS_RECORDED_BY_SCRIPT
- DEPENDABOT_TARGETS_BETA
- EXTENSION_HOST_TEST_LAYER
- FILE_SIZE_POLICY
Expand All @@ -62,6 +65,7 @@ keys:
- cache_issues
- compilation_errors
- component_not_loading
- demo_gif_recording_fails
- empty_changelog_conventionalcommits_v10
- extension_not_activating
- hover_not_working
Expand Down
34 changes: 34 additions & 0 deletions .ai/workflows.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,40 @@ workflows:
command: npm test
- action: commit_changes
command: 'git commit -m ''chore: update dependencies'''
record_demo_gifs:
description: Re-record the README demo GIFs (browse, complete, hover, validate, versions) after a UI change
location: scripts/demo-gifs/
prerequisites:
- macOS with VS Code installed at /Applications (override with VSCODE_BIN / VSCODE_CLI)
- ffmpeg on PATH
- Network access to gitlab.com (scenes use public components/opentofu, sast, secret-detection, code-quality)
- Run outside any command sandbox; VS Code needs its mach port and debug port
- Root dependencies installed (npm ci at the repo root); vsce package runs the root vscode:prepublish build
steps:
- action: install_recorder_dependencies
command: cd scripts/demo-gifs && npm ci
note: Own package.json/lock, separate from the extension's; root npm install never touches it
- action: record
command: npm run record
note: 'All scenes, or name some: npm run record -- hover versions. Packages the extension with vsce,
installs it into a throwaway profile under /tmp/gch-demo (GCH_DEMO_DIR, which is wiped each run and must be
named gch-demo*), drives VS Code over CDP on a free debug port,
writes out/<scene>.gif and out/<scene>-review.png'
- action: privacy_review
check: Open every out/<scene>-review.png (one tile per second) and confirm no names, paths, accounts,
private projects or tokens appear. The profile is fresh and the folder is my-pipelines, but check anyway
- action: publish
steps:
- Drag the GIFs into a comment on the README PR on GitHub and post it
- Copy the github.com/user-attachments/assets/<id> URLs from the comment into README.md
- Confirm each URL loads without logging in (curl -sL -o /dev/null -w '%{http_code}' <url> returns 200)
note: GIFs are not committed; see decisions.yaml DEMO_GIFS_RECORDED_BY_SCRIPT
- action: add_or_change_a_scene
location: scripts/demo-gifs/scenes/{name}.mjs
contract: Export cursorStart, prepare(page) and perform(page); register it in SCENES in record.mjs
helpers: lib/vscode.mjs (prepareScene, runCommand, webviewFrame), lib/input.mjs (moveTo, clickOn, typeSlow,
wordPosition, scrollIntoView)
reference: scripts/demo-gifs/record.mjs
common_commands:
compile: npm run compile
watch: npm run watch
Expand Down
11 changes: 11 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ module.exports = [
...js.configs.recommended.rules,
},
},
{
// Demo GIF recorder, a maintainer tool. Its page.evaluate callbacks run in the workbench, hence browser globals.
files: ['scripts/demo-gifs/**/*.mjs'],
languageOptions: {
sourceType: 'module',
globals: { ...globals.node, ...globals.browser },
},
rules: {
...js.configs.recommended.rules,
},
},
{
// Webview client scripts run in the Electron renderer, not the extension host
files: ['src/webview/client/**/*.ts'],
Expand Down
59 changes: 59 additions & 0 deletions scripts/demo-gifs/fixtures/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"workbench.startupEditor": "none",
"workbench.tips.enabled": false,
"workbench.colorTheme": "Dark Modern",
"editor.minimap.enabled": false,
"editor.fontSize": 15,
"editor.lineHeight": 24,
"editor.hover.delay": 300,
"editor.stickyScroll.enabled": false,
"editor.renderWhitespace": "none",
"telemetry.telemetryLevel": "off",
"update.mode": "none",
"extensions.autoCheckUpdates": false,
"extensions.autoUpdate": "off",
"extensions.ignoreRecommendations": true,
"workbench.enableExperiments": false,
"chat.disableAIFeatures": true,
"security.workspace.trust.enabled": false,
"git.enabled": false,
"breadcrumbs.enabled": false,
"window.commandCenter": false,
"workbench.layoutControl.enabled": false,
"window.restoreWindows": "none",
"files.autoSave": "off",
"workbench.secondarySideBar.defaultVisibility": "hidden",
"gitlabComponentHelper.logLevel": "ERROR",
"gitlabComponentHelper.componentSources": [
{
"name": "OpenTofu",
"path": "components/opentofu",
"gitlabInstance": "gitlab.com"
},
{
"name": "SAST",
"path": "components/sast",
"gitlabInstance": "gitlab.com"
},
{
"name": "Secret Detection",
"path": "components/secret-detection",
"gitlabInstance": "gitlab.com"
},
{
"name": "Code Quality",
"path": "components/code-quality",
"gitlabInstance": "gitlab.com"
}
],
"workbench.statusBar.visible": true,
"editor.quickSuggestions": {
"other": true,
"comments": false,
"strings": true
},
"redhat.telemetry.enabled": false,
"yaml.schemaStore.enable": false,
"window.dialogStyle": "custom",
"editor.wordBasedSuggestions": "off"
}
16 changes: 16 additions & 0 deletions scripts/demo-gifs/fixtures/templates/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
spec:
inputs:
environment:
description: Where to deploy.
options: [staging, production]
app_name:
description: Name of the app to deploy.
replicas:
type: number
default: 2
description: How many pods to run.
---
deploy-$[[ inputs.environment ]]:
stage: deploy
script:
- ./deploy.sh "$[[ inputs.app_name ]]" "$[[ inputs.environment ]]" "$[[ inputs.replicas ]]"
38 changes: 38 additions & 0 deletions scripts/demo-gifs/lib/config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

export const DEMO_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
export const REPO_ROOT = path.resolve(DEMO_DIR, '../..');
export const FIXTURES_DIR = path.join(DEMO_DIR, 'fixtures');
export const OUT_DIR = path.join(DEMO_DIR, 'out');

const WORK_DIR_PREFIX = 'gch-demo';

/** The work dir is wiped on every run, so only accept a folder whose own name marks it as ours. */
function resolveWorkDir(requested) {
const dir = path.resolve(requested);
const isOurs = path.basename(dir).startsWith(WORK_DIR_PREFIX);
const holdsSomethingPrecious = [os.homedir(), REPO_ROOT].some((p) => !path.relative(dir, p).startsWith('..'));
if (!isOurs || holdsSomethingPrecious) {
throw new Error(`GCH_DEMO_DIR must be a throwaway folder named ${WORK_DIR_PREFIX}*, got ${dir}`);
}
return dir;
}

// VS Code's IPC socket lives in the profile and its path must stay under ~103 characters, so keep this short.
export const WORK_DIR = resolveWorkDir(process.env.GCH_DEMO_DIR ?? '/tmp/gch-demo');
export const PROFILE_DIR = path.join(WORK_DIR, 'profile');
export const EXTENSIONS_DIR = path.join(WORK_DIR, 'extensions');
// The folder name shows in the window title, so it doubles as the demo's project name.
export const WORKSPACE_DIR = path.join(WORK_DIR, 'my-pipelines');
export const FRAMES_DIR = path.join(WORK_DIR, 'frames');
export const VSIX_PATH = path.join(WORK_DIR, 'extension.vsix');

const VSCODE_APP = '/Applications/Visual Studio Code.app/Contents';
export const VSCODE_BIN = process.env.VSCODE_BIN ?? `${VSCODE_APP}/MacOS/Code`;
export const VSCODE_CLI = process.env.VSCODE_CLI ?? `${VSCODE_APP}/Resources/app/bin/code`;

export const VIEWPORT = { width: 1280, height: 800 };
export const DEVICE_SCALE = 2;
export const GIF = { width: 1100, fps: 12, colors: 192 };
86 changes: 86 additions & 0 deletions scripts/demo-gifs/lib/input.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Mouse, keyboard, and a drawn cursor. The screencast doesn't capture the OS pointer, so the scripts draw their own
// in the top document (above the webview iframes) and move it in step with Playwright's synthetic mouse.
import { VIEWPORT } from './config.mjs';
import { sleep } from './vscode.mjs';

const CURSOR_ID = 'demo-cursor';
const CURSOR_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='22' height='28' viewBox='0 0 22 28'><path d='M2 2 L2 22 L7.5 17 L11 25 L14.5 23.5 L11 15.8 L18 15.8 Z' fill='white' stroke='black' stroke-width='1.6' stroke-linejoin='round'/></svg>";
const FRAME_MS = 16;
const TOP_SAFE_PX = 90;

let cursor = { x: VIEWPORT.width / 2, y: VIEWPORT.height / 2 };

export async function showCursor(page, x, y) {
await page.evaluate(({ id, svg, x, y }) => {
document.getElementById(id)?.remove();
const el = document.createElement('div');
el.id = id;
el.style.cssText = `position:fixed;left:0;top:0;width:22px;height:28px;z-index:2147483647;pointer-events:none;transform:translate(${x}px,${y}px);background:url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}") no-repeat`;
document.body.appendChild(el);
}, { id: CURSOR_ID, svg: CURSOR_SVG, x, y });
await page.mouse.move(x, y);
cursor = { x, y };
}

const easeInOut = (t) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);

export async function moveTo(page, x, y, ms = 500) {
const steps = Math.max(8, Math.round(ms / FRAME_MS));
const from = cursor;
for (let i = 1; i <= steps; i++) {
const e = easeInOut(i / steps);
const point = { x: from.x + (x - from.x) * e, y: from.y + (y - from.y) * e };
await page.evaluate(({ id, point }) => {
const el = document.getElementById(id);
if (el) el.style.transform = `translate(${point.x}px,${point.y}px)`;
}, { id: CURSOR_ID, point });
await page.mouse.move(point.x, point.y);
await sleep(ms / steps);
}
cursor = { x, y };
}

export async function centerOf(locator) {
const box = await locator.boundingBox();
if (!box) throw new Error('Element has no bounding box');
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}

export async function clickOn(page, locator, { ms = 600, pause = 250 } = {}) {
const { x, y } = await centerOf(locator);
await moveTo(page, x, y, ms);
await sleep(pause);
await page.mouse.click(x, y);
}

/** Middle of `word` on the first editor line containing `lineText`. */
export async function wordPosition(page, lineText, word) {
const line = page.locator('.view-line', { hasText: lineText }).first();
return centerOf(line.locator('span span', { hasText: word }).first());
}

/** Type with a little jitter so it reads as a person typing. */
export async function typeSlow(page, text, delay = 70) {
for (const ch of text) {
await (ch === '\n' ? page.keyboard.press('Enter') : page.keyboard.type(ch));
await sleep(delay + Math.random() * 30);
}
}

export async function wheel(page, deltaY, { steps = 10, ms = 600 } = {}) {
for (let i = 0; i < steps; i++) {
await page.mouse.wheel(0, deltaY / steps);
await sleep(ms / steps);
}
}

/** Scroll the pane under `x` with the wheel until `locator` is on screen, the way a person would. */
export async function scrollIntoView(page, locator, x, { margin = 40 } = {}) {
const MAX_SCROLLS = 12;
for (let i = 0; i < MAX_SCROLLS; i++) {
const box = await locator.boundingBox();
if (box && box.y > TOP_SAFE_PX && box.y + box.height < VIEWPORT.height - margin) return;
await moveTo(page, x, VIEWPORT.height / 2, 250);
await wheel(page, box && box.y < TOP_SAFE_PX ? -200 : 200, { steps: 6, ms: 300 });
}
}
Loading
Loading