From 566cfce76cc4d96a957e014024096caf06fb5c44 Mon Sep 17 00:00:00 2001 From: Everett Date: Tue, 27 Oct 2026 10:53:41 -0700 Subject: [PATCH 1/2] Show runnable JSX and live widgets on the homepage --- .github/workflows/pages.yml | 2 + Web/studio/scripts/hero-smoke.ts | 91 +++++++++++++++++ Web/studio/scripts/onboarding-smoke.ts | 4 +- Web/studio/vite.config.ts | 2 + site/growth.css | 31 +++++- site/hero.js | 135 +++++++++++++++++++++++++ site/index.html | 17 +++- 7 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 Web/studio/scripts/hero-smoke.ts create mode 100644 site/hero.js diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 327721ac..00762098 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -37,6 +37,8 @@ jobs: - name: Build Web Studio working-directory: Web/studio run: npm ci && npm test && npm run build + - name: Verify homepage code and preview + run: node Web/studio/scripts/hero-smoke.ts - name: Assemble site run: | mkdir -p _site diff --git a/Web/studio/scripts/hero-smoke.ts b/Web/studio/scripts/hero-smoke.ts new file mode 100644 index 00000000..75f9a47f --- /dev/null +++ b/Web/studio/scripts/hero-smoke.ts @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { launchChrome, serveDirectory, clickReady } from '../../preview-engine/scripts/lib/chrome.mjs'; + +const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const assembled = fs.mkdtempSync(path.join(os.tmpdir(), 'scriptwidget-hero-')); +// Exercise the deployed /scriptwidget/ prefix, including sandbox/support URLs. +const root = path.join(assembled, 'scriptwidget'); +fs.cpSync(path.join(repository, 'site'), root, { recursive: true }); +fs.cpSync(path.join(repository, 'Web/studio/dist'), path.join(root, 'studio'), { recursive: true }); +const routes: Record = {}; +const server = await serveDirectory(assembled, routes); +const chrome = await launchChrome({ width: 1440, height: 1000, scale: 1 }); +const screenshots = process.env.SCRIPTWIDGET_HERO_SCREENSHOTS; +if (screenshots) fs.mkdirSync(screenshots, { recursive: true }); +try { + const page = await chrome.newPage(); + await page.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }); + await page.goto(`${server.origin}/scriptwidget/`); + await page.waitFor("!!document.querySelector('#hero-preview .sw-widget')", 20000); + const originalSource = await page.evaluate("document.getElementById('hero-source').textContent"); + const links: Array<{ href: string; source: string; name: string }> = []; + for (const [index, name] of ['Reading habit', 'Weather', 'Countdown'].entries()) { + await clickReady(page, `document.querySelectorAll('[data-hero-example]')[${index}]`); + await page.waitFor(`document.getElementById('hero-preview').dataset.example===${JSON.stringify(name)} && document.getElementById('hero-preview').getAttribute('aria-busy')==='false'`); + assert.equal(await page.evaluate("document.querySelectorAll('[data-hero-example][aria-pressed=true]').length"), 1); + const treeBefore = await page.evaluate("document.querySelector('#hero-preview .sw-widget').outerHTML"); + await clickReady(page, "document.querySelector('.theme-token')"); + await page.waitFor("document.getElementById('hero-preview').getAttribute('aria-busy')==='false'"); + const source = await page.evaluate("document.getElementById('hero-source').textContent"); + assert.notEqual(await page.evaluate("document.querySelector('#hero-preview .sw-widget').outerHTML"), treeBefore, 'Changing source color must change rendered output'); + assert.equal(await page.evaluate("document.activeElement.classList.contains('theme-token')"), true); + links.push({ href: await page.evaluate("document.getElementById('hero-open').href"), source, name }); + assert.match(links.at(-1)!.href, /\/scriptwidget\/studio\/#sw-preview=v1\./); + assert.match(await page.evaluate("document.getElementById('hero-status').textContent"), /sample/); + if (screenshots) { + await page.evaluate('scrollTo(0,0)'); + fs.writeFileSync(path.join(screenshots, `desktop-${index}.png`), await page.screenshot()); + } + } + // Rapid changes must not let an older async render overwrite the latest. + await page.evaluate("document.querySelectorAll('[data-hero-example]')[0].click();document.querySelector('.theme-token').click();document.querySelectorAll('[data-hero-example]')[2].click()"); + await page.waitFor("document.getElementById('hero-preview').dataset.example==='Countdown' && document.getElementById('hero-preview').getAttribute('aria-busy')==='false'"); + for (const width of [320, 375, 390, 768, 1024, 1440]) { + await page.call('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false }); + await page.evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + assert.equal(await page.evaluate('document.documentElement.scrollWidth<=innerWidth'), true, `Page overflow at ${width}`); + assert.equal(await page.evaluate("document.getElementById('hero-preview').scrollWidth<=document.getElementById('hero-preview').clientWidth"), true, `Preview overflow at ${width}`); + if (screenshots) { await page.evaluate('scrollTo(0,0)'); fs.writeFileSync(path.join(screenshots, `layout-${width}.png`), await page.screenshot()); } + } + await page.call('Emulation.setDeviceMetricsOverride', { width: 1440, height: 1000, deviceScaleFactor: 1, mobile: false }); + // Each link must import the exact visible code, with prior drafts preserved. + let projectCount = 0; + for (const { href, source, name } of links) { + await page.goto(href); + await page.waitFor("document.getElementById('save-state')?.textContent==='Saved in this browser' && !!document.querySelector('.sw-widget')", 20000); + assert.equal(await page.evaluate('location.hash'), ''); + const saved = await page.evaluate<{ files: Record }>(`(async()=>{ + const db=await new Promise(resolve=>{const r=indexedDB.open('scriptwidget-studio',1);r.onsuccess=()=>resolve(r.result)}); + try{return await new Promise(resolve=>{const r=db.transaction('projects').objectStore('projects').get(JSON.parse(localStorage.getItem('scriptwidget-studio.project')));r.onsuccess=()=>resolve(r.result)})}finally{db.close()} + })()`); + assert.equal(saved.files['main.jsx'], source, `${name} must open with the visible theme and full source`); + if (screenshots) { + const folder = path.join(screenshots, 'packages', name); + fs.mkdirSync(folder, { recursive: true }); + for (const file of ['main.jsx', 'widget.json']) fs.writeFileSync(path.join(folder, file), saved.files[file]!); + } + const count = await page.evaluate("document.querySelectorAll('#project-list li').length"); + assert.ok(count > projectCount, 'Handoff must create a project rather than overwrite the previous draft'); + projectCount = count; + } + await page.goto(`${server.origin}/scriptwidget/`); + await page.waitFor("!!document.querySelector('#hero-preview .sw-widget')", 20000); + assert.equal(await page.evaluate("document.getElementById('hero-source').textContent"), originalSource); + // A blocked runtime offers an honest fallback and a working editing link. + routes['/scriptwidget/studio/sandbox.html'] = { type: 'text/html', body: 'Runtime unavailable' }; + await page.call('Network.enable'); + await page.call('Network.setCacheDisabled', { cacheDisabled: true }); + await page.goto(`${server.origin}/scriptwidget/?fallback-check`); + await page.waitFor("document.getElementById('hero-status').textContent.includes('could not render')", 15000); + assert.match(await page.evaluate("document.getElementById('hero-preview').textContent"), /unavailable/); + assert.match(await page.evaluate("document.getElementById('hero-open').href"), /#sw-preview=v1\./); + console.log('Hero: 3 real JSX previews, theme changes, rapid switching, 6 screen widths, exact Studio handoff, draft preservation, reload, blocked-runtime fallback passed.'); +} finally { + await chrome.close(); + server.close(); + fs.rmSync(assembled, { recursive: true, force: true }); +} diff --git a/Web/studio/scripts/onboarding-smoke.ts b/Web/studio/scripts/onboarding-smoke.ts index beb075e8..d8d7b4c7 100644 --- a/Web/studio/scripts/onboarding-smoke.ts +++ b/Web/studio/scripts/onboarding-smoke.ts @@ -25,11 +25,11 @@ try { } await page.call("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: "light" }, { name: "prefers-reduced-motion", value: "reduce" }] }); await page.goto(`${server.origin}/`); - await page.waitFor("[...document.querySelectorAll('.widget-showcase img')].every(i=>i.complete&&i.naturalWidth>0)"); + await page.waitFor("!!document.querySelector('#hero-preview .sw-widget')"); assert.equal(await page.evaluate("document.querySelectorAll('.hero .actions a').length"), 3); assert.equal(await page.evaluate("document.querySelector('.hero .actions a:first-child').classList.contains('primary')"), true); assert.equal(await page.evaluate("document.querySelector('.hero .actions a:first-child').href"), "https://apps.apple.com/us/app/scriptwidget/id1555600758"); - assert.equal(await page.evaluate("document.querySelectorAll('.widget-showcase img').length"), 3); + assert.equal(await page.evaluate("document.querySelectorAll('[data-hero-example]').length"), 3); assert.equal(await page.evaluate("new URL(document.querySelector('.hero .actions a:last-child').href).pathname"), "/studio/"); const output = process.env.SCRIPTWIDGET_ONBOARDING_SCREENSHOTS; if (output) { fs.mkdirSync(output, { recursive: true }); fs.writeFileSync(path.join(output, "home-desktop.png"), await page.screenshot()); } diff --git a/Web/studio/vite.config.ts b/Web/studio/vite.config.ts index 4a8af896..2cc74a11 100644 --- a/Web/studio/vite.config.ts +++ b/Web/studio/vite.config.ts @@ -97,7 +97,9 @@ export default defineConfig({ chunkSizeWarningLimit: 600, // Libraries change less often than the app: separate chunks cache longer. rollupOptions: { + input: { studio: path.join(root, "index.html"), hero: path.join(repository, "site/hero.js") }, output: { + entryFileNames: (chunk) => chunk.name === "hero" ? "hero/demo.js" : "assets/[name]-[hash].js", manualChunks(id) { if (/node_modules\/(react|react-dom|scheduler|radix-ui|@radix-ui|sonner|@floating-ui)\//.test(id)) return "ui"; if (/node_modules\/(@codemirror|@lezer|crelt|style-mod|w3c-keyname)\//.test(id)) return "editor"; diff --git a/site/growth.css b/site/growth.css index 682f4bcf..c3c59e44 100644 --- a/site/growth.css +++ b/site/growth.css @@ -54,5 +54,34 @@ @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}.button{transition:none}.button:hover{transform:none}} .copy-block code{white-space:pre-wrap;overflow-wrap:anywhere} -.hero h1{font-size:clamp(32px,5vw,60px);line-height:1.04} +.hero h1{font-size:clamp(32px,4vw,50px);line-height:1.04} .hero h1 span{display:block} + +/* The source and preview share one runnable example, with no autoplay. */ +.hero{grid-template-columns:.95fr 1.05fr;gap:36px} +.code-showcase{min-width:0;background:#fff;border:1px solid var(--line);border-radius:22px;box-shadow:0 16px 48px #17202a08;overflow:hidden} +.demo-heading{display:flex;align-items:center;gap:9px;padding:20px 20px 12px} +.demo-heading h2{font-size:17px;line-height:1.4;letter-spacing:-.025em;margin:0} +.demo-dot{width:8px;height:8px;border-radius:50%;background:#258557} +.demo-tabs{display:flex;gap:4px;padding:0 16px 16px;flex-wrap:wrap} +.demo-tabs button{font-family:inherit;line-height:1.4;font-size:12px;font-weight:600;color:var(--muted);background:transparent;border:1px solid transparent;border-radius:8px;padding:8px 10px;cursor:pointer} +.demo-tabs button[aria-pressed=true]{background:#edf5ef;border-color:#d7e7dc;color:#226342} +.demo-workspace{display:grid;grid-template-columns:minmax(0,1fr) 212px;border-block:1px solid var(--line)} +.demo-editor{min-width:0;background:#fafbf9;border-right:1px solid var(--line)} +.demo-pane-label{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)} +.demo-pane-label span{font-size:10px;color:#88908d} +.demo-editor pre{font:11px/1.85 ui-monospace,SFMono-Regular,Menlo,monospace;overflow:auto;margin:0;padding:8px 14px 20px;height:306px;tab-size:2;color:#33443b} +.syntax-keyword{color:#6855a5}.syntax-string{color:#287447} +.theme-token{font:inherit;border:0;border-bottom:2px dotted var(--swatch);padding:0 2px;background:#e9f2ec;border-radius:3px;cursor:pointer} +.demo-result{min-width:0;display:flex;flex-direction:column;background:linear-gradient(145deg,#fcfdfb,#eef3ec)} +#hero-preview{min-height:240px;display:flex;align-items:center;justify-content:center;font-size:12px;padding:0 2px;text-align:center} +.hero-widget-frame{flex-shrink:0;border-radius:22px;overflow:hidden;filter:drop-shadow(0 8px 12px #243d2410)} +#hero-status{font-size:10px;line-height:1.5;text-align:center;color:var(--muted);padding:0 10px;margin:auto 0 18px} +.demo-footer{padding:16px 18px;display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center} +.demo-footer span{font-size:11px;color:var(--muted)} +.demo-footer a{font-size:12px;font-weight:700;color:#226342;text-underline-offset:4px} +.demo-fallback{padding:0 18px;font-size:13px} +.demo-tabs button:focus-visible,.theme-token:focus-visible,.demo-footer a:focus-visible,.demo-editor pre:focus-visible{outline:3px solid #0a84ff;outline-offset:2px} +@media(max-width:1100px) and (min-width:821px){.hero{grid-template-columns:1fr}.code-showcase{width:100%;max-width:720px}.demo-workspace{grid-template-columns:minmax(0,1fr) 240px}} +@media(max-width:820px){.hero{grid-template-columns:1fr}.code-showcase{width:100%}} +@media(max-width:540px){.demo-workspace{grid-template-columns:1fr}.demo-editor{border-right:0;border-bottom:1px solid var(--line)}.demo-editor pre{height:240px;font-size:12px}.demo-result{min-height:245px}#hero-preview{min-height:180px}.demo-pane-label{padding-block:10px}#hero-status{margin:8px 0 16px}.demo-footer{padding:14px}.demo-heading{padding-inline:14px}} diff --git a/site/hero.js b/site/hero.js new file mode 100644 index 00000000..fbd39e8f --- /dev/null +++ b/site/hero.js @@ -0,0 +1,135 @@ +import { createPreviewSandbox } from '../Web/preview-engine/src/browser/client.js'; +import { renderWidget } from '../Web/preview-engine/src/render/renderer.js'; +import { newProjectFiles, exportPackage } from '../Web/preview-engine/src/package/index.js'; + +// These permission-free examples are the exact source executed by the shared +// sandbox and exported to Studio. Sample data never writes check-in history. +const examples = [ + { + name: 'Reading habit', family: 'small', label: 'Reading habit · sample check-ins', + source: (color) => `const accent = "${color}"; +const days = Array.from({ length: 49 }, (_, i) => i % 5 !== 1 || i >= 37); +$render( + + Read every day + + {Array.from({ length: 7 }, (_, week) => + {days.slice(week * 7, week * 7 + 7).map(done => + )} + )} + + 12 day streak + +);`, + }, + { + name: 'Weather', family: 'small', label: 'Weather · sample forecast', + source: (color) => `const accent = "${color}"; +const forecast = { city: "London", temperature: "18°" }; +$render( + + {forecast.city} + + {forecast.temperature} + Partly cloudy + +);`, + }, + { + name: 'Countdown', family: 'small', label: 'Countdown · sample event', + source: (color) => `const accent = "${color}"; +const trip = { name: "Next adventure", days: 18 }; +$render( + + + + {trip.days} days + {trip.name} + +);`, + }, +]; +const colors = ['#258557', '#5468cf', '#bd6633']; +const code = document.getElementById('hero-source'); +const preview = document.getElementById('hero-preview'); +const status = document.getElementById('hero-status'); +const link = document.getElementById('hero-open'); +const studio = new URL('studio/', document.baseURI); +let selected = 0; +let theme = 0; +let generation = 0; +const sandbox = createPreviewSandbox({ sandboxURL: new URL('sandbox.html', studio), assetBaseURL: new URL('runtime/', studio), startupTimeoutMilliseconds: 10000 }); + +function highlight(source) { + code.replaceChildren(); + // Tokenize only for display; never insert source as HTML or evaluate it in + // the landing page. JSX is compiled/executed inside the existing sandbox. + const tokens = /("[^"\n]*"|\b(?:const|true|false)\b|\$render|<\/?[a-z]+|\b\d+\b)/g; + let offset = 0; + for (const match of source.matchAll(tokens)) { + code.append(document.createTextNode(source.slice(offset, match.index))); + const token = match[0]; + const node = document.createElement(token === `"${colors[theme]}"` ? 'button' : 'span'); + node.textContent = token; + node.className = token.startsWith('"') ? 'syntax-string' : 'syntax-keyword'; + if (node.tagName === 'BUTTON') { + node.type = 'button'; + node.className += ' theme-token'; + node.style.setProperty('--swatch', colors[theme]); + node.setAttribute('aria-label', 'Change widget color'); + node.addEventListener('click', () => { theme = (theme + 1) % colors.length; void update(true); }); + } + code.append(node); + offset = match.index + token.length; + } + code.append(document.createTextNode(source.slice(offset))); +} + +async function update(restoreFocus = false) { + const current = ++generation; + const example = examples[selected]; + const source = example.source(colors[theme]); + highlight(source); + if (restoreFocus) code.querySelector('button').focus({ preventScroll: true }); + document.querySelectorAll('[data-hero-example]').forEach((button, index) => { + button.setAttribute('aria-pressed', String(index === selected)); + }); + const files = newProjectFiles(`Homepage ${example.name}`, source); + const archive = exportPackage(files); + if (archive.error) throw new Error(archive.error); + const payload = btoa(String.fromCharCode(...archive.bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + link.href = `${studio}#sw-preview=v1.${payload}`; + status.textContent = 'Rendering JSX…'; + preview.setAttribute('aria-busy', 'true'); + try { + await sandbox.ready; + const result = await sandbox.execute({ files, manifest: archive.manifest, environments: { 'widget-size': example.family } }); + if (current !== generation) return; + if (result.error || !result.tree) throw new Error(result.error?.message ?? 'No widget output.'); + const rendered = await renderWidget(result.tree, { family: example.family, profile: 'iphone-393', appearance: 'light' }); + if (current !== generation) return; + const scale = example.family === 'medium' ? 0.61 : 1; + const frame = document.createElement('div'); + frame.className = 'hero-widget-frame'; + frame.style.width = `${rendered.size.width * scale}px`; + frame.style.height = `${rendered.size.height * scale}px`; + rendered.element.style.transformOrigin = 'top left'; + rendered.element.style.transform = `scale(${scale})`; + frame.append(rendered.element); + preview.replaceChildren(frame); + preview.setAttribute('aria-label', example.label); + preview.dataset.example = example.name; + status.textContent = example.label; + } catch { + if (current !== generation) return; + preview.textContent = 'Preview unavailable. Open this example in Web Studio to try it.'; + status.textContent = 'The browser could not render the preview.'; + } finally { + if (current === generation) preview.setAttribute('aria-busy', 'false'); + } +} + +document.querySelectorAll('[data-hero-example]').forEach((button, index) => { + button.addEventListener('click', () => { selected = index; void update(); }); +}); +void update(); diff --git a/site/index.html b/site/index.html index 7874d77e..a3cc3d9c 100644 --- a/site/index.html +++ b/site/index.html @@ -1,11 +1,22 @@ -ScriptWidget — Native widgets with JavaScript + JSX +ScriptWidget — Native widgets with JavaScript + JSX -
Open source · iOS · iPadOS · macOS

Build native widgets. With JavaScript + JSX.

Create widgets with familiar JSX-style JavaScript. Write and preview in Web Studio, or let your favorite Agent help.

Use your own Agent and model. Try the browser preview without installing anything.

Read habit heatmap with completed days and a 12-day streak
Habit Heatmap · sample check-ins
Spring break countdown widget
Countdown
Reading Goal widget showing 7 of 12 books
Reading Goal

Real built-in templates, rendered on Mac with example data.

+
Open source · iOS · iPadOS · macOS

Build native widgets. With JavaScript + JSX.

Create widgets with familiar JSX-style JavaScript. Write and preview in Web Studio, or let your favorite Agent help.

Use your own Agent and model. Try the browser preview without installing anything.

+

A little JSX. Your own widget.

+
+ +
+
+
main.jsx JavaScript + JSX
Loading example…
+
Live preview
Starting preview…

Runs in your browser.

+
+ + +
JavaScriptCore runtimeNative SwiftUI renderingLive preview on MacCommunity gallery

Describe. Generate. Preview.

1. Install the Creator Skill

Copy scriptwidget-creator into Claude Code’s skills folder. Keep using your own Agent and model.

2. Ask for your widget

Use /scriptwidget-creator to generate native JavaScript + JSX with the right runtime APIs and package permissions.

3. See it immediately

The CLI opens Web Studio with the generated widget already loaded. Validate with native Mac previews and export a .swt for iPhone.

/scriptwidget-creator Create a reading habit widget
and show it in Web Studio.

From an idea to a native widget.

Write in JavaScript + JSX

Compose familiar declarative layouts and use versioned APIs for data, files, networking, storage, device state, and more.

Build in Studio

Edit with diagnostics, inspect runtime output, and preview multiple widget families without repeating an Xcode build loop.

Share real packages

Use versioned manifests, explicit permissions, host allowlists, and the community Gallery to distribute reusable widgets.

JSX in. Native WidgetKit out.

$render(
  <vstack frame="max" padding="16" background="#171923">
    <text font="caption" color="#a9b0c0">Today</text>
    <text font="largeTitle" color="#ffffff">Build something useful.</text>
  </vstack>
);

Build the widget platform with us.

Star the repository to follow releases, open an issue with a real workflow, or contribute a template to the verified Gallery.

ScriptWidget stars on GitHub
- + From 04cec0ca80fb49dfcef86824f445e985faffebbf Mon Sep 17 00:00:00 2001 From: Everett Date: Tue, 27 Oct 2026 10:54:22 -0700 Subject: [PATCH 2/2] Check homepage previews across browser engines in CI --- .github/workflows/web-browsers.yml | 5 +++ Web/studio/scripts/hero-browsers.ts | 47 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 Web/studio/scripts/hero-browsers.ts diff --git a/.github/workflows/web-browsers.yml b/.github/workflows/web-browsers.yml index 9c93a713..72e8f9c4 100644 --- a/.github/workflows/web-browsers.yml +++ b/.github/workflows/web-browsers.yml @@ -7,12 +7,14 @@ on: push: paths: - 'Web/**' + - 'site/**' - 'Editor/editorfe/src/**' - 'Shared/ScriptWidgetRuntime/Widget/Resource/support.bundle/**' - '.github/workflows/web-browsers.yml' pull_request: paths: - 'Web/**' + - 'site/**' - 'Editor/editorfe/src/**' - '.github/workflows/web-browsers.yml' workflow_dispatch: @@ -56,3 +58,6 @@ jobs: - name: Cross-browser checks working-directory: Web/studio run: node scripts/cross-browser.ts chromium firefox webkit + - name: Homepage code and preview across browsers + working-directory: Web/studio + run: node scripts/hero-browsers.ts chromium firefox webkit diff --git a/Web/studio/scripts/hero-browsers.ts b/Web/studio/scripts/hero-browsers.ts new file mode 100644 index 00000000..c80884bb --- /dev/null +++ b/Web/studio/scripts/hero-browsers.ts @@ -0,0 +1,47 @@ +// Verify the landing-page integration across the same browser engines as Studio. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium, firefox, webkit } from 'playwright'; +import { serveDirectory } from '../../preview-engine/scripts/lib/chrome.mjs'; + +const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'scriptwidget-hero-browsers-')); +fs.cpSync(path.join(repository, 'site'), root, { recursive: true }); +fs.cpSync(path.join(repository, 'Web/studio/dist'), path.join(root, 'studio'), { recursive: true }); +const server = await serveDirectory(root); +const engines = { chromium, firefox, webkit }; +const requested = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(engines); +try { + for (const name of requested) { + assert.ok(name in engines, `Unknown browser: ${name}`); + const browser = await engines[name as keyof typeof engines].launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(server.origin); + for (const example of ['Reading habit', 'Weather', 'Countdown']) { + await page.getByRole('button', { name: example, exact: true }).click(); + await page.waitForFunction(name => document.getElementById('hero-preview')?.dataset.example === name + && !!document.querySelector('#hero-preview .sw-widget') + && document.getElementById('hero-preview')?.getAttribute('aria-busy') === 'false', example); + const source = await page.locator('#hero-source').textContent(); + const link = await page.locator('#hero-open').getAttribute('href'); + await page.getByRole('button', { name: 'Change widget color' }).click(); + await page.waitForFunction(() => document.getElementById('hero-preview')?.getAttribute('aria-busy') === 'false'); + assert.notEqual(await page.locator('#hero-source').textContent(), source); + assert.notEqual(await page.locator('#hero-open').getAttribute('href'), link); + } + await page.setViewportSize({ width: 375, height: 844 }); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + assert.deepEqual(errors, []); + console.log(`${name}: three JSX previews, theme/source/link changes, mobile layout and zero page errors passed.`); + } finally { await browser.close(); } + } +} finally { + server.close(); + fs.rmSync(root, { recursive: true, force: true }); +}