// // End-to-end: activates the real extension against a real git checkout and a // canned Gitea instance, then renders every sidebar section and drives the // commands that touch the filesystem or the API. // const { EXTENSION_ROOT } = require("./nova-stub.js"); const fake = require("./fake-gitea.js"); const { Checker } = require("./assert.js"); const path = require("node:path"); const fs = require("node:fs"); const os = require("node:os"); const { execFileSync } = require("node:child_process"); const script = (...parts) => path.join(EXTENSION_ROOT, "Scripts", ...parts); const t = new Checker(); // -- fixture --------------------------------------------------------------- const WORKSPACE = fs.mkdtempSync(path.join(os.tmpdir(), "gitea-nova-test-")); const REPO = path.join(WORKSPACE, "widget"); function buildRepository() { fs.mkdirSync(path.join(REPO, "src"), { recursive: true }); fs.writeFileSync( path.join(REPO, "src", "login.js"), Array.from({ length: 20 }, (_, i) => `// line ${i + 1}`).join("\n") + "\n", ); fs.writeFileSync(path.join(REPO, "README.md"), "# widget\n"); const git = (...args) => execFileSync("git", args, { cwd: REPO, encoding: "utf-8" }); git("init", "-q", "-b", "main"); git("config", "user.email", "test@example.com"); git("config", "user.name", "Test"); git("remote", "add", "origin", "git@gitea.test:acme/widget.git"); git("add", "-A"); git("commit", "-qm", "initial"); git("checkout", "-qb", "feature/login"); } buildRepository(); nova.workspace.path = WORKSPACE; nova.config.set("gitea.baseUrl", "https://gitea.test"); nova.credentials.setPassword("unsupervised.giteanova", "https://gitea.test", "tok_test"); // -- helpers --------------------------------------------------------------- /** Renders a provider's tree to indented text, expanding lazy rows as it goes. */ async function render(provider, node = null, depth = 0, lines = []) { for (const child of await provider.getChildren(node)) { const item = provider.getTreeItem(child); const description = item.descriptiveText ? ` [${item.descriptiveText}]` : ""; lines.push(`${" ".repeat(depth)}${item.name}${description}`); if (depth < 3 && (child.children || child.load)) { await render(provider, child, depth + 1, lines); } } return lines; } /** Every command name the manifest exposes to the user. */ function declaredCommands(manifest) { const declared = new Set(); for (const list of Object.values(manifest.commands || {})) { for (const entry of list) if (entry.command) declared.add(entry.command); } for (const sidebar of manifest.sidebars || []) { for (const section of sidebar.sections || []) { const entries = [...(section.headerCommands || []), ...(section.contextCommands || [])]; for (const entry of entries) if (entry.command) declared.add(entry.command); } } const walk = (entry) => { if (entry.command) declared.add(entry.command); for (const child of entry.children || []) walk(child); }; for (const entry of manifest.config || []) walk(entry); return declared; } // -- suite ----------------------------------------------------------------- (async () => { const main = require(script("main.js")); main.activate(); await new Promise((resolve) => setTimeout(resolve, 400)); // let activation settle const store = require(script("controllers", "selection.js")).store(); const views = Object.fromEntries(global.__treeViews.map((view) => [view.id, view])); t.section("discovery"); console.log( store.repos .map((r) => `${r.fullName} @ ${r.baseUrl} branch=${r.branch}`) .join("\n") || "(none)", ); t.check("one repository discovered", store.repos.length === 1, `got ${store.repos.length}`); t.check("routed to the matching instance", store.repos[0]?.baseUrl === "https://gitea.test"); t.check("branch read from git", store.repos[0]?.branch === "feature/login", store.repos[0]?.branch); t.check( "instance reported healthy", store.instanceStatus.get("https://gitea.test")?.ok === true, JSON.stringify(store.instanceStatus.get("https://gitea.test")), ); for (const id of ["gitea.branch", "gitea.workflows", "gitea.pullRequests", "gitea.settings"]) { t.section(id); const lines = await render(views[id].dataProvider); console.log(lines.join("\n")); t.check(`${id} renders rows`, lines.length > 0); t.check( `${id} renders no error rows`, !lines.some((line) => line.includes("⚠")), lines.filter((line) => line.includes("⚠")).join(" | "), ); } t.section("branch filtering"); const branchLines = await render(views["gitea.branch"].dataProvider); t.check("shows the branch's pull request", branchLines.some((l) => l.includes("#7 Add login form"))); t.check("hides another branch's pull request", !branchLines.some((l) => l.includes("#6 Bump deps"))); t.check("hides another branch's run", !branchLines.some((l) => l.includes("Nightly"))); t.section("workflow grouping"); const workflowLines = await render(views["gitea.workflows"].dataProvider); t.check( "grouped by workflow file", workflowLines.some((l) => l.trim().startsWith("ci.yml")) && workflowLines.some((l) => l.trim().startsWith("nightly.yml")), ); t.check("jobs load under a run", workflowLines.some((l) => l.includes("build"))); t.check("steps load under a job", workflowLines.some((l) => l.includes("Checkout"))); t.check("artifacts listed under a run", workflowLines.some((l) => l.includes("coverage"))); t.section("inline review comments"); const { ReviewCommentsController } = require(script("controllers", "reviewComments.js")); const comments = new ReviewCommentsController(store); await comments.reload({ force: true }); const annotated = [...comments.collection.map.entries()]; for (const [uri, issues] of annotated) { console.log(uri); for (const issue of issues) console.log(` line ${issue.line} [${issue.source}] ${issue.message}`); } t.check("exactly one file annotated", annotated.length === 1, `got ${annotated.length}`); t.check("annotation lands on the commented file", annotated[0]?.[0].endsWith("/src/login.js")); // The hunk adds three lines after one context line starting at new line 10, // so the commented line — the hunk's last — is line 14. t.check("line derived from the diff hunk", annotated[0]?.[1][0].line === 14, annotated[0]?.[1][0].line); t.check("request-changes comment raised as an error", annotated[0]?.[1][0].severity === IssueSeverity.Error); comments.dispose(); t.section("manifest and command registration"); const manifest = JSON.parse(fs.readFileSync(path.join(EXTENSION_ROOT, "extension.json"), "utf-8")); const declared = declaredCommands(manifest); const registered = global.__commands; const missing = [...declared].filter((name) => !registered.has(name)); t.check("every command the manifest offers is registered", missing.length === 0, missing.join(", ")); // gitea.openReviewComment is deliberately absent: it is a TreeItem.command // for double-click, never a menu entry. const extra = [...registered.keys()].filter((name) => !declared.has(name)); t.equals("only the double-click handler is manifest-free", extra, ["gitea.openReviewComment"]); t.section("command behaviour"); const branch = views["gitea.branch"]; const roots = await branch.dataProvider.getChildren(null); const pullFolder = roots.find((n) => n.kind === "prFolder"); const pullNode = (await branch.dataProvider.getChildren(pullFolder)).find((n) => n.kind === "pr"); branch.selection = [pullNode]; await registered.get("gitea.copyUrl")(branch); t.check( "copy URL uses the pull request's own page", global.__clipboard === "https://gitea.test/acme/widget/pulls/7", global.__clipboard, ); await registered.get("gitea.openPullRequestDiff")(branch); t.check("diff saved into the repository", fs.existsSync(path.join(REPO, ".tmp", "gitea-diffs", "pr-7.diff"))); await registered.get("gitea.openPullRequestOverview")(branch); const overview = global.__opened?.untitled?.content ?? ""; t.check("overview headed with the pull request title", overview.includes("# #7 Add login form")); t.check("overview lists reviews", overview.includes("alice") && overview.includes("request_changes")); t.check("overview lists changed files", overview.includes("src/login.js")); const runsFolder = roots.find((n) => n.kind === "runsFolder"); const runNode = (await branch.dataProvider.getChildren(runsFolder)).find((n) => n.kind === "run"); const jobNode = (await branch.dataProvider.getChildren(runNode)).find((n) => n.kind === "job"); branch.selection = [jobNode]; await registered.get("gitea.viewJobLogs")(branch); const logsDir = path.join(REPO, ".tmp", "gitea-logs"); const logFiles = fs.existsSync(logsDir) ? fs.readdirSync(logsDir) : []; t.check("job log written into the repository", logFiles.length === 1, logFiles.join(", ")); const workflows = views["gitea.workflows"]; const ciGroup = (await workflows.dataProvider.getChildren(null)).find( (n) => n.kind === "workflow" && n.name === "ci.yml", ); const finishedRun = (await workflows.dataProvider.getChildren(ciGroup)).find( (n) => n.data.run.id === 101, ); const artifactsFolder = (await workflows.dataProvider.getChildren(finishedRun)).find( (n) => n.kind === "artifactsFolder", ); workflows.selection = [(await workflows.dataProvider.getChildren(artifactsFolder))[0]]; await registered.get("gitea.downloadArtifact")(workflows); const zip = path.join(REPO, ".tmp", "gitea-artifacts", "acme", "widget", "101", "coverage.zip"); t.check("artifact downloaded to the configured path", fs.existsSync(zip), zip); t.section("secrets and variables"); const settingsLines = await render(views["gitea.settings"].dataProvider); console.log(settingsLines.join("\n")); t.check("secret listed", settingsLines.some((l) => l.includes("DEPLOY_KEY"))); t.check("variable listed", settingsLines.some((l) => l.includes("REGISTRY"))); t.section("write operations"); global.__messages.length = 0; workflows.selection = [finishedRun]; await registered.get("gitea.rerunRun")(workflows); t.check( "re-run reported as requested", global.__messages.some(([kind, message]) => kind === "info" && message.includes("Re-run")), JSON.stringify(global.__messages), ); global.__messages.length = 0; global.__inputAnswer = "Looks good to me"; branch.selection = [pullNode]; await registered.get("gitea.reviewApprove")(branch); t.check( "approval submitted", global.__messages.some(([kind, message]) => kind === "info" && message.includes("Review submitted")), JSON.stringify(global.__messages), ); t.section("the sidebar keeps its shape across refreshes"); // A poll that rebuilds every row used to close whatever the user had open. const workflowsView = views["gitea.workflows"]; const workflowsProvider = workflowsView.dataProvider; const group = (await workflowsProvider.getChildren(null)).find( (n) => n.kind === "workflow" && n.name === "ci.yml", ); const openMe = (await workflowsProvider.getChildren(group)).find((n) => n.data.run.id === 101); t.equals( "a run starts collapsed", workflowsProvider.getTreeItem(openMe).collapsibleState, TreeItemCollapsibleState.Collapsed, ); workflowsView.expand(group); workflowsView.expand(openMe); // What a refresh does: throw the built tree away and rebuild it. workflowsProvider.invalidate(); const rebuiltGroup = (await workflowsProvider.getChildren(null)).find( (n) => n.kind === "workflow" && n.name === "ci.yml", ); const rebuiltRun = (await workflowsProvider.getChildren(rebuiltGroup)).find( (n) => n.data.run.id === 101, ); t.check("the rebuilt run is a different object", rebuiltRun !== openMe); t.equals("but carries the same identifier", rebuiltRun.identifier, openMe.identifier); t.equals( "and is still open", workflowsProvider.getTreeItem(rebuiltRun).collapsibleState, TreeItemCollapsibleState.Expanded, ); t.equals( "its parent too", workflowsProvider.getTreeItem(rebuiltGroup).collapsibleState, TreeItemCollapsibleState.Expanded, ); workflowsView.collapse(rebuiltRun); workflowsProvider.invalidate(); const closedAgain = (await workflowsProvider.getChildren( (await workflowsProvider.getChildren(null)).find((n) => n.name === "ci.yml"), )).find((n) => n.data.run.id === 101); t.equals( "closing it sticks too", workflowsProvider.getTreeItem(closedAgain).collapsibleState, TreeItemCollapsibleState.Collapsed, ); // A section that opens by default must still be closable. const branchProvider2 = views["gitea.branch"].dataProvider; const folder = (await branchProvider2.getChildren(null)).find((n) => n.kind === "prFolder"); t.equals( "a default-open folder starts open", branchProvider2.getTreeItem(folder).collapsibleState, TreeItemCollapsibleState.Expanded, ); views["gitea.branch"].collapse(folder); branchProvider2.invalidate(); const folderAgain = (await branchProvider2.getChildren(null)).find((n) => n.kind === "prFolder"); t.equals( "and stays closed once the user closes it", branchProvider2.getTreeItem(folderAgain).collapsibleState, TreeItemCollapsibleState.Collapsed, ); t.section("idle polls do not redraw"); const providers = Object.fromEntries( Object.entries(views).map(([id, view]) => [id, view.dataProvider]), ); const snapshot = () => Object.fromEntries(Object.entries(providers).map(([id, p]) => [id, p.signature()])); const before = snapshot(); t.check( "an unchanged store leaves every section's signature alone", Object.entries(snapshot()).every(([id, value]) => value === before[id]), ); // Run 102 is deliberately still in progress in the fixture. t.check( "a live run keeps an elapsed clock in the signature", providers["gitea.workflows"].signature().includes("tick:"), ); t.check( "a section with no live run has none", !providers["gitea.pullRequests"].signature().includes("tick:"), ); const runs = store.runsFor(store.repos[0].key).runs; const finished = runs.find((run) => run.id === 103); const wasConcluded = finished.conclusion; finished.conclusion = "success"; t.check("a run's conclusion changes the workflows section", providers["gitea.workflows"].signature() !== before["gitea.workflows"]); t.equals("but leaves the pull requests section alone", providers["gitea.pullRequests"].signature(), before["gitea.pullRequests"]); t.equals("and the settings section alone", providers["gitea.settings"].signature(), before["gitea.settings"]); finished.conclusion = wasConcluded; const pulls = store.pullRequestsFor(store.repos[0].key).items; const wasUpdated = pulls[0].updated_at; pulls[0].updated_at = new Date().toISOString(); t.check("a pull request update changes the pull requests section", providers["gitea.pullRequests"].signature() !== before["gitea.pullRequests"]); t.equals("but leaves the workflows section alone", providers["gitea.workflows"].signature(), before["gitea.workflows"]); pulls[0].updated_at = wasUpdated; t.check( "restoring the data restores every signature", Object.entries(snapshot()).every(([id, value]) => value === before[id]), ); nova.workspace.config.set("gitea.branchFilter", "all"); t.check("changing the branch filter redraws the branch section", providers["gitea.branch"].signature() !== before["gitea.branch"]); t.equals("without touching the workflows section", providers["gitea.workflows"].signature(), before["gitea.workflows"]); nova.workspace.config.set("gitea.branchFilter", "current"); // A run still in progress must keep its elapsed clock moving. const live = runs.find((run) => run.id === 102); t.equals("because the fixture leaves one running", live.status, "running"); t.section("only the rows that changed are reloaded"); // Watching a job means the tree updates constantly. Rebuilding all of it // would drop the scroll position, so only the affected rows are reloaded. const wfView = views["gitea.workflows"]; const wf = wfView.dataProvider; await wf.update(); t.equals("a second update with no changes reports nothing", await wf.update(), null); const wfGroups = await wf.getChildren(null); const ciGroupNode = wfGroups.find((n) => n.name === "ci.yml"); wfView.expand(ciGroupNode); const ciRuns = await wf.getChildren(ciGroupNode); const liveRun = ciRuns.find((n) => n.data.run.id === 102); const idleRun = ciRuns.find((n) => n.data.run.id === 101); // Open the running row, as someone monitoring a job would. wfView.expand(liveRun); await wf.getChildren(liveRun); t.check("the open run has cached children", Array.isArray(liveRun.children)); // The job finishes. const liveJobs = store.jobsFor(store.repos[0].key, 102); liveJobs[0].status = "completed"; liveJobs[0].conclusion = "success"; runs.find((run) => run.id === 102).status = "completed"; runs.find((run) => run.id === 102).conclusion = "success"; const targeted = await wf.update(); t.check("an update is reported", Boolean(targeted)); t.equals("without falling back to a full reload", targeted?.full, false); t.check( "and it names the run that changed", targeted?.nodes.includes(liveRun), targeted?.nodes.map((n) => n.identifier).join(", "), ); t.check( "not the untouched sibling", !targeted?.nodes.includes(idleRun), targeted?.nodes.map((n) => n.identifier).join(", "), ); t.check("the open row's children are dropped so they refetch", liveRun.children === null); // Node identity has to survive, or Nova cannot match the reload to a row. const groupsAfter = await wf.getChildren(null); t.check("group objects are reused", groupsAfter.includes(ciGroupNode)); t.check("run objects are reused", (await wf.getChildren(ciGroupNode)).includes(liveRun)); t.check("the row's label was updated in place", liveRun.name.includes("✓"), liveRun.name); // A new run changes its workflow group's row set, so that group is // reloaded — still not the whole tree. const newRun = (id, workflow) => ({ id, run_number: id, display_title: "New push", path: `.gitea/workflows/${workflow}`, event: "push", status: "queued", conclusion: "", head_branch: "feature/login", created_at: new Date().toISOString(), started_at: new Date().toISOString(), }); runs.unshift(newRun(104, "ci.yml")); const added = await wf.update(); t.equals("a new run does not force a full reload", added?.full, false); t.check( "it reloads the workflow group it belongs to", added?.nodes.includes(ciGroupNode), added?.nodes.map((n) => n.identifier).join(", "), ); runs.shift(); await wf.update(); // A whole new workflow adds a top-level row, which the row-by-row merge // cannot patch. runs.unshift(newRun(105, "release.yml")); const reshaped = await wf.update(); t.equals("a new workflow forces a full reload", reshaped?.full, true); runs.shift(); await wf.update(); t.section("the scroll anchor survives a reload"); // Nova cannot report scroll position, so the selected row is the anchor. const anchorView = views["gitea.workflows"]; const anchorProvider = anchorView.dataProvider; const anchorGroup = (await anchorProvider.getChildren(null)).find((n) => n.name === "ci.yml"); anchorView.expand(anchorGroup); const anchorRun = (await anchorProvider.getChildren(anchorGroup)).find( (n) => n.data.run.id === 101, ); anchorView.selection = [anchorRun]; anchorProvider.invalidate(); const found = await anchorProvider.findByIdentifier(anchorRun.identifier); t.check("the rebuilt row is located again", Boolean(found)); t.check("as a fresh object", found !== anchorRun); t.equals("with the same identity", found?.identifier, anchorRun.identifier); anchorView.selection = []; t.equals( "a row inside a collapsed branch is not searched for", await anchorProvider.findByIdentifier("step-9001-1"), null, ); t.section("api paths exercised"); console.log([...new Set(fake.calls.map((call) => call.split("?")[0]))].sort().join("\n")); main.deactivate(); fs.rmSync(WORKSPACE, { recursive: true, force: true }); t.finish("INTEGRATION CHECKS"); })().catch((error) => { console.error("HARNESS ERROR", error); fs.rmSync(WORKSPACE, { recursive: true, force: true }); process.exit(2); });