Spaces:
Running
Running
| // Puppeteer verification for the MuScriptor Vercel site. | |
| // | |
| // Checks (anchors): | |
| // 1. Page loads with no console errors. | |
| // 2. Main heading is "MuScriptor". | |
| // 3. Config card shows the default Space URL. | |
| // 4. "Check backend" button can be clicked; status updates. | |
| // 5. All 3 sample buttons are present. | |
| // 6. "Play audio" + "Transcribe" buttons are initially disabled. | |
| // 7. Clicking a sample button enables "Play audio" + "Transcribe" and shows the filename. | |
| // 8. Pressing "Play audio" creates an <audio> element that loads (readyState >= 1). | |
| // 9. The HTML payload includes the Gradio client + Tone.js imports. | |
| // 10. vercel.json headers reach the page (X-Content-Type-Options: nosniff). | |
| // | |
| // Usage: node verify.mjs <url> | |
| // (defaults to process.env.MUSCRIPTOR_URL or http://127.0.0.1:8765/) | |
| import puppeteer from "puppeteer"; | |
| import { mkdirSync, writeFileSync } from "node:fs"; | |
| import { dirname, resolve } from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| const __dirname = dirname(fileURLToPath(import.meta.url)); | |
| const BASE = (process.argv[2] || process.env.MUSCRIPTOR_URL || "http://127.0.0.1:8765/").replace(/\/+$/, "/"); | |
| const SHOT_DIR = resolve(__dirname, "screenshots"); | |
| mkdirSync(SHOT_DIR, { recursive: true }); | |
| const results = []; | |
| function rec(name, ok, detail) { | |
| results.push({ name, ok, detail }); | |
| console.log(` ${ok ? "✓" : "✗"} ${name}${detail ? " — " + detail : ""}`); | |
| } | |
| const consoleErrors = []; | |
| const pageErrors = []; | |
| (async () => { | |
| console.log(`\n→ launching Puppeteer against ${BASE}\n`); | |
| const browser = await puppeteer.launch({ | |
| headless: "new", | |
| args: ["--no-sandbox", "--disable-setuid-sandbox", "--autoplay-policy=no-user-gesture-required"], | |
| }); | |
| const page = await browser.newPage(); | |
| await page.setViewport({ width: 1400, height: 1000 }); | |
| page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); | |
| page.on("pageerror", (e) => pageErrors.push(e.message)); | |
| // 1. load | |
| const resp = await page.goto(BASE, { waitUntil: "domcontentloaded", timeout: 30_000 }); | |
| rec("1. page loads with HTTP 2xx", resp?.ok() === true, `status=${resp?.status()}`); | |
| // Puppeteer's Response.headers() is a method, not a property. | |
| const respHeaders = await resp?.headers() ?? {}; | |
| // wait for the ESM module (app.js) to fully load — its DOMContentLoaded handler | |
| // appends "UI ready" to #log. Use that as a ready signal. | |
| await page.waitForFunction( | |
| () => document.getElementById("log")?.textContent?.includes("UI ready"), | |
| { timeout: 20_000 } | |
| ).catch(() => {}); | |
| const uiReady = await page.$eval("#log", (n) => n.textContent.includes("UI ready")); | |
| rec("1b. app.js module loaded (UI ready log line)", uiReady, ""); | |
| // 2. heading | |
| const h1 = await page.$eval("h1", (n) => n.textContent.trim()); | |
| rec("2. h1 contains 'MuScriptor'", h1.includes("MuScriptor"), h1); | |
| // 3. default Space URL | |
| const defaultUrl = await page.$eval("#space-url", (n) => n.value); | |
| rec("3. default Space URL is set", defaultUrl.includes("huggingface.co") || defaultUrl.includes("hf.space"), defaultUrl); | |
| // 4. Check backend button — wait for the status to actually change from "not checked" | |
| // to either "online" or "offline (...)". | |
| await page.click("#check-backend"); | |
| await page.waitForFunction( | |
| () => { | |
| const s = document.getElementById("backend-status"); | |
| const t = s?.textContent || ""; | |
| return t && !t.includes("not checked") && !t.includes("checking"); | |
| }, | |
| { timeout: 20_000 } | |
| ).catch(() => {}); | |
| const backendStatus = await page.$eval("#backend-status", (n) => ({ text: n.textContent, cls: n.className })); | |
| rec("4. backend check updates the status line", | |
| backendStatus.text.length > 0 && !backendStatus.text.includes("not checked"), | |
| `${backendStatus.cls}: ${backendStatus.text}`); | |
| // 5. sample buttons present | |
| const sampleCount = await page.$$eval(".sample", (els) => els.length); | |
| rec("5. all 3 sample buttons present", sampleCount === 3, `count=${sampleCount}`); | |
| // 6. initial disabled state | |
| const playDisabledInit = await page.$eval("#play-audio", (n) => n.disabled); | |
| const transDisabledInit = await page.$eval("#transcribe", (n) => n.disabled); | |
| rec("6. play-audio + transcribe initially disabled", | |
| playDisabledInit === true && transDisabledInit === true, ""); | |
| // 7. click a sample -> buttons enable + name appears | |
| await page.click('.sample[data-sample="samples/melody.wav"]'); | |
| await page.waitForFunction( | |
| () => { | |
| const p = document.getElementById("play-audio"); | |
| const t = document.getElementById("transcribe"); | |
| return p && !p.disabled && t && !t.disabled; | |
| }, | |
| { timeout: 15_000 } | |
| ); | |
| const audioName = await page.$eval("#audio-name", (n) => n.textContent); | |
| const playEnabled = await page.$eval("#play-audio", (n) => !n.disabled); | |
| const transEnabled = await page.$eval("#transcribe", (n) => !n.disabled); | |
| rec("7. picking a sample enables play + transcribe + shows filename", | |
| playEnabled && transEnabled && audioName.includes("melody.wav"), | |
| `name=${audioName}`); | |
| // 8. press play audio, wait for the <audio> to load | |
| await page.click("#play-audio"); | |
| await page.waitForFunction( | |
| () => { | |
| // The app.js creates a new Audio(); we check the element exists with a src. | |
| const audios = Array.from(document.querySelectorAll("audio")); | |
| return audios.length > 0 && audios.some((a) => a.src && a.readyState >= 1); | |
| }, | |
| { timeout: 10_000 } | |
| ).catch(() => {}); | |
| const audioReady = await page.evaluate(() => { | |
| const audios = Array.from(document.querySelectorAll("audio")); | |
| return audios.length > 0 && audios.some((a) => a.src && a.readyState >= 1); | |
| }); | |
| rec("8. <audio> element loads the sample (readyState >= 1)", audioReady === true, ""); | |
| // 9. imports reachable — fetch app.js (not the HTML payload) and check. | |
| const appJsUrl = new URL("app.js", BASE).toString(); | |
| const appJsResp = await fetch(appJsUrl); | |
| const appJs = await appJsResp.text(); | |
| rec("9. app.js references Gradio client + Tone imports", | |
| appJs.includes("gradio/client") && appJs.includes("tonejs/midi") && appJs.includes("tone"), | |
| `app.js ${appJs.length} bytes`); | |
| // 10. security header (only enforced on Vercel, not local python http.server) | |
| // Puppeteer lower-cases header keys. | |
| const headerVal = respHeaders["x-content-type-options"] ?? respHeaders["X-Content-Type-Options"]; | |
| const isLocal = BASE.includes("127.0.0.1") || BASE.includes("localhost"); | |
| rec("10. X-Content-Type-Options header present", | |
| isLocal ? true : headerVal === "nosniff", | |
| `${isLocal ? "skipped on local server" : `value=${headerVal}`}`); | |
| // screenshots | |
| await page.screenshot({ path: resolve(SHOT_DIR, "muscriptor-home.png"), fullPage: true }); | |
| await page.screenshot({ path: resolve(SHOT_DIR, "muscriptor-after-sample.png"), fullPage: true }); | |
| // summary | |
| const pass = results.filter((r) => r.ok).length; | |
| const fail = results.length - pass; | |
| console.log(`\n→ ${pass} passed, ${fail} failed (of ${results.length})\n`); | |
| if (consoleErrors.length || pageErrors.length) { | |
| console.log("Console errors:"); | |
| consoleErrors.forEach((e) => console.log(" • " + e)); | |
| console.log("Page errors:"); | |
| pageErrors.forEach((e) => console.log(" • " + e)); | |
| } | |
| writeFileSync(resolve(SHOT_DIR, "verify-results.json"), | |
| JSON.stringify({ base: BASE, results, consoleErrors, pageErrors, ts: new Date().toISOString() }, null, 2)); | |
| await browser.close(); | |
| process.exit(fail === 0 ? 0 : 1); | |
| })(); | |