mirror of
https://github.com/estruyf/vscode-front-matter.git
synced 2026-11-10 02:12:22 +01:00
350 lines
14 KiB
JavaScript
350 lines
14 KiB
JavaScript
// Opens the real extension in real VS Code, against the demo site in
|
|
// ../demo/tidewater, and hands back the workbench page for the shot list to
|
|
// drive. Called only by capture.mjs.
|
|
//
|
|
// Everything that could differ between two runs is pinned or thrown away:
|
|
//
|
|
// - VS Code itself is a pinned build, downloaded once into .cache/vscode, not
|
|
// whatever is installed on the machine doing the capture.
|
|
// - The extension is the packaged VSIX (`npm run vsix`), installed into an
|
|
// empty extensions folder. That is what runs in production mode and loads
|
|
// the webviews from dist/ — exactly what someone installing it from the
|
|
// Marketplace gets. `--extensionDevelopmentPath` would load them from the
|
|
// webpack dev server instead.
|
|
// - The profile, the extensions folder and the workspace are fresh copies in
|
|
// the temp dir on every run, so no state leaks from one capture to the next
|
|
// and nothing a beat writes ever lands in demo/.
|
|
// - Every file in the workspace gets the same mtime, DEMO_NOW, so nothing the
|
|
// dashboard sorts or labels by modification date depends on when the
|
|
// checkout happened.
|
|
|
|
import { _electron as electron } from 'playwright';
|
|
import { downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath } from '@vscode/test-electron';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = resolve(HERE, '..');
|
|
export const DEMO_DIR = join(ROOT, 'demo/tidewater');
|
|
const CACHE = join(ROOT, '.cache');
|
|
export const VSIX = join(CACHE, 'front-matter.vsix');
|
|
|
|
/// The VS Code build every capture is taken in. Bump it deliberately: a new
|
|
/// build moves the workbench chrome around, and every crop is measured off it.
|
|
export const VSCODE_VERSION = process.env.PROMO_VSCODE ?? '1.139.1';
|
|
|
|
/// The window, in CSS pixels. Captured at 2x. Narrower than a real laptop on
|
|
/// purpose — the workbench lays out the same, and a narrower window makes 13px
|
|
/// editor text a larger share of a 1080p frame.
|
|
export const VIEWPORT = { width: 1280, height: 820 };
|
|
|
|
/// Every date in ../demo is written against this day, and every file's mtime is
|
|
/// set to it. The drafts are dated after it; everything published, before.
|
|
export const DEMO_NOW = new Date('2026-09-24T10:24:00Z');
|
|
|
|
/// The user settings of the throwaway profile. Everything here is something a
|
|
/// real user could have set; nothing reaches into the extension.
|
|
const USER_SETTINGS = {
|
|
'workbench.colorTheme': 'Default Dark Modern',
|
|
'workbench.startupEditor': 'none',
|
|
'workbench.tips.enabled': false,
|
|
'workbench.enableExperiments': false,
|
|
'workbench.layoutControl.enabled': false,
|
|
'workbench.secondarySideBar.defaultVisibility': 'hidden',
|
|
'workbench.editor.empty.hint': 'hidden',
|
|
'window.restoreWindows': 'none',
|
|
'window.commandCenter': false,
|
|
'window.title': '${rootName}',
|
|
'telemetry.telemetryLevel': 'off',
|
|
'update.mode': 'none',
|
|
'extensions.autoUpdate': false,
|
|
'extensions.autoCheckUpdates': false,
|
|
'extensions.ignoreRecommendations': true,
|
|
'security.workspace.trust.enabled': false,
|
|
'chat.disableAIFeatures': true,
|
|
'chat.commandCenter.enabled': false,
|
|
'git.enabled': false,
|
|
'editor.minimap.enabled': false,
|
|
'editor.wordWrap': 'on',
|
|
'editor.fontSize': 13,
|
|
'editor.lineHeight': 21,
|
|
'editor.renderWhitespace': 'none',
|
|
'editor.stickyScroll.enabled': false,
|
|
'editor.lightbulb.enabled': 'off',
|
|
// A blinking caret makes every recorded frame differ from the last, and a
|
|
// still can land on either phase.
|
|
'editor.cursorBlinking': 'solid',
|
|
'files.autoSave': 'off',
|
|
'markdown.validate.enabled': false,
|
|
};
|
|
|
|
// ---- the pointer ---------------------------------------------------------
|
|
//
|
|
// CDP's screencast composites the page and not the cursor, so a recording of a
|
|
// click is a recording of something happening for no visible reason. This puts
|
|
// a dot back. It is the only thing in any frame that the app did not draw.
|
|
//
|
|
// Unlike a plain web app, most of what gets clicked here is inside a webview —
|
|
// an iframe in another process — and its mouse events never reach the
|
|
// workbench document. So the dot does not listen for mousemove; `pointTo` below
|
|
// sets it to the same coordinates it hands `page.mouse`, step for step, and it
|
|
// can no more drift from the click than listening could.
|
|
|
|
const POINTER = (accent) => {
|
|
const make = (id, css) => {
|
|
const el = document.createElement('div');
|
|
el.id = id;
|
|
el.style.cssText = css.join(';');
|
|
document.body.append(el);
|
|
return el;
|
|
};
|
|
const base = ['position:fixed', 'left:0', 'top:0', 'width:22px', 'height:22px', 'margin:-11px 0 0 -11px',
|
|
'border-radius:50%', 'pointer-events:none', 'opacity:0'];
|
|
const dot = make('__promo_pointer', [...base, 'z-index:2147483647',
|
|
'background:rgba(255,255,255,0.35)', 'border:2px solid #fff', 'box-shadow:0 2px 10px rgba(0,0,0,0.55)',
|
|
'transition:opacity .2s, transform .12s']);
|
|
const ring = make('__promo_ring', [...base, 'z-index:2147483646', `border:3px solid ${accent}`]);
|
|
let x = 0;
|
|
let y = 0;
|
|
window.__promo = {
|
|
move(nx, ny) {
|
|
x = nx;
|
|
y = ny;
|
|
dot.style.opacity = '1';
|
|
dot.style.transform = `translate(${x}px, ${y}px)`;
|
|
ring.style.transform = `translate(${x}px, ${y}px)`;
|
|
},
|
|
down() {
|
|
dot.style.transform = `translate(${x}px, ${y}px) scale(0.75)`;
|
|
ring.style.transition = 'none';
|
|
ring.style.opacity = '0.9';
|
|
ring.style.transform = `translate(${x}px, ${y}px) scale(1)`;
|
|
requestAnimationFrame(() => {
|
|
ring.style.transition = 'opacity .45s ease-out, transform .45s ease-out';
|
|
ring.style.opacity = '0';
|
|
ring.style.transform = `translate(${x}px, ${y}px) scale(2.6)`;
|
|
});
|
|
},
|
|
up() {
|
|
dot.style.transform = `translate(${x}px, ${y}px) scale(1)`;
|
|
},
|
|
show(v) {
|
|
dot.style.display = v ? '' : 'none';
|
|
ring.style.display = v ? '' : 'none';
|
|
},
|
|
};
|
|
};
|
|
|
|
const current = { x: 0, y: 0 };
|
|
|
|
/** Move the real mouse and the drawn dot together, in `steps` steps. */
|
|
export async function pointTo(page, x, y, { steps = 26 } = {}) {
|
|
const from = { ...current };
|
|
for (let i = 1; i <= steps; i++) {
|
|
// Ease in and out, so a move reads as a hand rather than a conveyor.
|
|
const t = i / steps;
|
|
const e = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
|
|
const px = from.x + (x - from.x) * e;
|
|
const py = from.y + (y - from.y) * e;
|
|
await page.mouse.move(px, py);
|
|
await page.evaluate(([a, b]) => window.__promo?.move(a, b), [px, py]);
|
|
}
|
|
current.x = x;
|
|
current.y = y;
|
|
}
|
|
|
|
export async function pressMouse(page) {
|
|
await page.evaluate(() => window.__promo?.down());
|
|
await page.mouse.down();
|
|
}
|
|
|
|
export async function releaseMouse(page) {
|
|
await page.mouse.up();
|
|
await page.evaluate(() => window.__promo?.up());
|
|
}
|
|
|
|
/** Hide the dot for the stills. */
|
|
export const hidePointer = (page) => page.evaluate(() => window.__promo?.show(false));
|
|
export const showPointer = (page) => page.evaluate(() => window.__promo?.show(true));
|
|
|
|
// ---- setting up ----------------------------------------------------------
|
|
|
|
function freshDir(path) {
|
|
rmSync(path, { recursive: true, force: true });
|
|
mkdirSync(path, { recursive: true });
|
|
return path;
|
|
}
|
|
|
|
/** Stamp every file and folder with DEMO_NOW. */
|
|
function pinMtimes(dir) {
|
|
for (const entry of readdirSync(dir)) {
|
|
const path = join(dir, entry);
|
|
if (statSync(path).isDirectory()) pinMtimes(path);
|
|
utimesSync(path, DEMO_NOW, DEMO_NOW);
|
|
}
|
|
utimesSync(dir, DEMO_NOW, DEMO_NOW);
|
|
}
|
|
|
|
/** The environment VS Code is launched with. When the capture is itself run
|
|
* from a VS Code terminal, the parent's VSCODE_* variables would make the child
|
|
* try to talk to the parent instead of starting on its own. */
|
|
function cleanEnv() {
|
|
const env = { ...process.env };
|
|
for (const key of Object.keys(env)) {
|
|
if (key.startsWith('VSCODE_') || key === 'ELECTRON_RUN_AS_NODE' || key === 'ELECTRON_NO_ATTACH_CONSOLE') {
|
|
delete env[key];
|
|
}
|
|
}
|
|
return env;
|
|
}
|
|
|
|
/** Launch VS Code on a fresh copy of the demo site, ready to drive. */
|
|
export async function openApp({ pointer = false, accent = '#15c2cb', ready = true } = {}) {
|
|
if (!existsSync(VSIX)) {
|
|
throw new Error(`no ${VSIX} — run npm run vsix first`);
|
|
}
|
|
|
|
let executable = await downloadAndUnzipVSCode({ version: VSCODE_VERSION, cachePath: join(CACHE, 'vscode') });
|
|
// Recent macOS builds name the binary `Code`; test-electron still guesses
|
|
// `Electron`.
|
|
if (!existsSync(executable) && existsSync(join(dirname(executable), 'Code'))) {
|
|
executable = join(dirname(executable), 'Code');
|
|
}
|
|
|
|
// Short paths in the temp dir: VS Code puts IPC sockets under the profile, and
|
|
// macOS caps a socket path at 104 bytes.
|
|
const run = join(tmpdir(), 'fm-promo');
|
|
const profile = freshDir(join(run, 'u'));
|
|
const extensions = freshDir(join(run, 'x'));
|
|
const workspace = join(freshDir(join(run, 'w')), 'tidewater');
|
|
|
|
mkdirSync(join(profile, 'User'), { recursive: true });
|
|
writeFileSync(join(profile, 'User/settings.json'), JSON.stringify(USER_SETTINGS, null, 2));
|
|
|
|
const [cli, ...cliArgs] = resolveCliArgsFromVSCodeExecutablePath(executable);
|
|
execFileSync(cli, [...cliArgs, '--user-data-dir', profile, '--extensions-dir', extensions,
|
|
'--install-extension', VSIX, '--force'], { stdio: 'ignore', env: cleanEnv() });
|
|
|
|
cpSync(DEMO_DIR, workspace, { recursive: true });
|
|
pinMtimes(workspace);
|
|
|
|
const app = await electron.launch({
|
|
executablePath: executable,
|
|
env: cleanEnv(),
|
|
args: [
|
|
workspace,
|
|
`--user-data-dir=${profile}`,
|
|
`--extensions-dir=${extensions}`,
|
|
'--skip-welcome',
|
|
'--skip-release-notes',
|
|
'--disable-workspace-trust',
|
|
'--disable-telemetry',
|
|
'--disable-updates',
|
|
'--disable-crash-reporter',
|
|
'--new-window',
|
|
// The screencast composites at CSS resolution, so the surface is forced
|
|
// to 2x here rather than trusting whatever display the window opens on.
|
|
'--force-device-scale-factor=2',
|
|
'--force-color-profile=srgb',
|
|
],
|
|
});
|
|
|
|
const page = await app.firstWindow();
|
|
await page.waitForSelector('.monaco-workbench', { timeout: 120_000 });
|
|
|
|
// The window, sized from the main process: the renderer cannot resize itself.
|
|
await app.evaluate(({ BrowserWindow }, size) => {
|
|
const win = BrowserWindow.getAllWindows()[0];
|
|
win.setContentSize(size.width, size.height);
|
|
win.center();
|
|
}, VIEWPORT);
|
|
|
|
if (pointer) {
|
|
await page.evaluate(POINTER, accent);
|
|
}
|
|
|
|
// The dashboard opens itself on a Front Matter workspace (openOnStart in the
|
|
// demo's frontmatter.json). Its content cards are the sign that the extension
|
|
// is up and has read the site.
|
|
if (ready) {
|
|
const dash = await dashboard(page);
|
|
await dash.locator('img').first().waitFor({ state: 'visible', timeout: 120_000 });
|
|
await settle(page, 1500);
|
|
await clearNotifications(page);
|
|
}
|
|
await settle(page, 1500);
|
|
|
|
return { app, page, workspace };
|
|
}
|
|
|
|
// ---- finding things ------------------------------------------------------
|
|
|
|
/// A webview is two iframes deep — VS Code's host frame and the document the
|
|
/// extension rendered inside it — and the host frames do not live in the editor
|
|
/// or the sidebar's DOM at all: they sit in an overlay laid over them. So the
|
|
/// dashboard and the panel are found by what they rendered, not where. Both
|
|
/// render an `#app`; only the dashboard's carries `data-webview-url`.
|
|
///
|
|
/// Looked up afresh on every call. A webview that is hidden and shown again can
|
|
/// come back as a new frame, and a stale handle fails silently.
|
|
async function findFrame(page, selector, timeout = 60_000) {
|
|
const until = Date.now() + timeout;
|
|
while (Date.now() < until) {
|
|
for (const frame of page.frames()) {
|
|
if (frame === page.mainFrame()) continue;
|
|
const hit = await frame.$(selector).catch(() => null);
|
|
if (hit) return frame;
|
|
}
|
|
await page.waitForTimeout(250);
|
|
}
|
|
throw new Error(`no webview with ${selector}`);
|
|
}
|
|
|
|
export const dashboard = (page) => findFrame(page, '#app[data-webview-url]');
|
|
export const panel = (page) => findFrame(page, '#app:not([data-webview-url])');
|
|
|
|
/** Close every toast. A fresh profile always gets the extension's "updated to"
|
|
* notification, and VS Code has its own. */
|
|
export async function clearNotifications(page) {
|
|
await runCommand(page, 'Notifications: Clear All Notifications');
|
|
}
|
|
|
|
/** Wait for the app to stop moving. */
|
|
export async function settle(page, ms = 500) {
|
|
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
|
await page.waitForTimeout(ms);
|
|
}
|
|
|
|
/** Run a VS Code command through the palette. Only ever used while setting up a
|
|
* shot, never inside a recording: in a video it would read as a step the viewer
|
|
* has to perform. */
|
|
export async function runCommand(page, title) {
|
|
await page.keyboard.press('Meta+Shift+P');
|
|
const input = page.locator('.quick-input-widget input');
|
|
await input.waitFor({ state: 'visible' });
|
|
await input.fill(`>${title}`);
|
|
// The list filters a beat after the input changes. Pressing Enter on the
|
|
// first row before then runs whatever matched the previous text — which is
|
|
// how a "close the sidebar" once opened the secondary one instead.
|
|
const first = page.locator('.quick-input-list .monaco-list-row').first();
|
|
await page.waitForFunction(
|
|
([sel, want]) => {
|
|
const row = document.querySelector(sel);
|
|
const label = row?.querySelector('.label-name')?.textContent?.trim() ?? '';
|
|
return label.replace(/\s+/g, ' ') === want;
|
|
},
|
|
['.quick-input-list .monaco-list-row', title],
|
|
{ timeout: 10_000 },
|
|
).catch(async (error) => {
|
|
const seen = await page.locator('.quick-input-list .monaco-list-row .label-name').allTextContents();
|
|
throw new Error(`runCommand: no "${title}" in the palette; it lists ${JSON.stringify(seen.slice(0, 6))}`, { cause: error });
|
|
});
|
|
await first.waitFor({ state: 'visible' });
|
|
await page.waitForTimeout(150);
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForTimeout(400);
|
|
}
|