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
52 changes: 35 additions & 17 deletions public/scripts/main.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,16 @@
/* global bootstrap */
import { parseRepositoryURL, generateWorkflow } from "./workflow.js";
import {
parseRepositoryURL,
detectDefaultBranch,
generateWorkflow,
} from "./workflow.js";

function navigateTo(url) {
window.open(url, "_blank");
}
window.navigateTo = navigateTo;

function generateWorkflowWithFormInputs() {
return generateWorkflow({
useBatchToken: document.getElementById("use-batch-token").checked,
useVirtualDisplay: document.getElementById("use-virtual-display").checked,
buildAcrossPlatforms: document.getElementById("build-across-platforms")
.checked,
siteUrl:
window.location.origin + window.location.pathname.replace(/\/[^/]*$/, ""),
});
}

function handleFormSubmit(e) {
async function handleFormSubmit(e) {
e.preventDefault();

const repoField = document.getElementById("repo");
Expand All @@ -28,7 +21,18 @@ function handleFormSubmit(e) {
}
repoField.classList.remove("is-invalid");

const workflow = generateWorkflowWithFormInputs();
let branch = await detectDefaultBranch(repoInfo);
if (!branch) branch = "main";

const workflow = generateWorkflow({
useBatchToken: document.getElementById("use-batch-token").checked,
useVirtualDisplay: document.getElementById("use-virtual-display").checked,
buildAcrossPlatforms: document.getElementById("build-across-platforms")
.checked,
siteUrl:
window.location.origin + window.location.pathname.replace(/\/[^/]*$/, ""),
branch,
});

const encoded = encodeURIComponent(workflow);
const filePath = ".github/workflows/matlab.yml";
Expand All @@ -37,7 +41,7 @@ function handleFormSubmit(e) {
if (repoInfo.enterprise) {
url += `/enterprises/${repoInfo.enterprise}`;
}
url += `/${repoInfo.owner}/${repoInfo.repo}/new/main?filename=${filePath}&value=${encoded}`;
url += `/${repoInfo.owner}/${repoInfo.repo}/new/${branch}?filename=${filePath}&value=${encoded}`;

window.navigateTo(url);

Expand All @@ -50,10 +54,24 @@ function showDownloadAlert() {
alert.focus();
}

function handleDownloadClick(e) {
async function handleDownloadClick(e) {
e.preventDefault();

const workflow = generateWorkflowWithFormInputs();
const repoField = document.getElementById("repo");
const repoInfo = parseRepositoryURL(repoField.value.trim());

let branch = await detectDefaultBranch(repoInfo);
if (!branch) branch = "main";

const workflow = generateWorkflow({
useBatchToken: document.getElementById("use-batch-token").checked,
useVirtualDisplay: document.getElementById("use-virtual-display").checked,
buildAcrossPlatforms: document.getElementById("build-across-platforms")
.checked,
siteUrl:
window.location.origin + window.location.pathname.replace(/\/[^/]*$/, ""),
branch,
});

const blob = new Blob([workflow], { type: "text/yaml" });
const url = URL.createObjectURL(blob);
Expand Down
85 changes: 82 additions & 3 deletions public/scripts/workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function parseRepositoryURL(repoURL) {
// Enterprise: http(s)://github.com/enterprises/enterprise/owner/repo
return {
origin: url.origin,
hostname: url.hostname,
enterprise: parts[1],
owner: parts[2],
repo: parts[3],
Expand All @@ -41,6 +42,7 @@ function parseRepositoryURL(repoURL) {
// Standard: http(s)://host/owner/repo
return {
origin: url.origin,
hostname: url.hostname,
owner: parts[0],
repo: parts[1],
};
Expand All @@ -51,11 +53,88 @@ function parseRepositoryURL(repoURL) {
return null;
}

async function detectDefaultBranch(repoInfo) {
if (!repoInfo) return null;

async function fetchWithTimeout(url, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000); // 3 seconds timeout
try {
const resp = await fetch(url, {
...options,
signal: controller.signal,
});
return resp;
} finally {
clearTimeout(timeout);
}
}

// Try to detect default branch using the GitHub API
async function tryApi() {
let apiUrl;
if (repoInfo.hostname.replace(/^www\./, "") === "github.com") {
apiUrl = `https://api.github.com/repos/${repoInfo.owner}/${repoInfo.repo}`;
} else {
apiUrl = `${repoInfo.origin}/api/v3/repos/${repoInfo.owner}/${repoInfo.repo}`;
}

try {
const resp = await fetchWithTimeout(apiUrl, {
headers: { Accept: "application/vnd.github+json" },
});
if (!resp.ok) return null;

const data = await resp.json();
return data.default_branch || null;
} catch {
// Network or abort error, fallback
return null;
}
}

// Fallback: Probe common branch names by checking for a README.md
async function tryBlobFallback() {
let repoBaseUrl;
if (repoInfo.hostname.replace(/^www\./, "") === "github.com") {
repoBaseUrl = "https://github.com";
} else {
repoBaseUrl = `${repoInfo.origin}`;
}
if (repoInfo.enterprise) {
repoBaseUrl += `/enterprises/${repoInfo.enterprise}`;
}
repoBaseUrl += `/${repoInfo.owner}/${repoInfo.repo}`;

const branches = ["main", "master", "develop"];
const checks = branches.map(async (branch) => {
const url = `${repoBaseUrl}/blob/${branch}/README.md`;
try {
const resp = await fetchWithTimeout(url, {
method: "HEAD",
redirect: "manual",
});
return resp.ok ? branch : null;
} catch {
return null;
}
});
const results = await Promise.all(checks);
return results.find((branch) => branch !== null) || null;
}

// Main logic: Try API first, then fallback if needed
const apiBranch = await tryApi();
if (apiBranch) return apiBranch;
return await tryBlobFallback();
}

function generateWorkflow({
useBatchToken = false,
useVirtualDisplay = false,
buildAcrossPlatforms = false,
siteUrl = "http://localhost/",
branch = "main",
}) {
return dedent(`
# This workflow was generated using the GitHub Actions Workflow Generator for MATLAB.
Expand All @@ -65,9 +144,9 @@ function generateWorkflow({

on:
push:
branches: [main]
branches: [${branch}]
pull_request:
branches: [main]
branches: [${branch}]
workflow_dispatch: {}
${
useBatchToken
Expand Down Expand Up @@ -145,4 +224,4 @@ function dedent(str) {
return match ? str.replace(new RegExp("^" + match[0], "gm"), "") : str;
}

export { parseRepositoryURL, generateWorkflow };
export { parseRepositoryURL, detectDefaultBranch, generateWorkflow };
71 changes: 66 additions & 5 deletions tests/main.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,64 +20,121 @@ beforeEach(async () => {
<div id="download-alert" class="d-none"></div>
`;

const realWorkflow = await import("../public/scripts/workflow.js");
jest.unstable_mockModule("../public/scripts/workflow.js", () => ({
...realWorkflow,
detectDefaultBranch: jest.fn().mockResolvedValue("main"),
}));

await import("../public/scripts/main.js");
window.navigateTo = jest.fn();
});

test("form submit with invalid repo shows error", () => {
const flushPromises = () => new Promise((r) => setTimeout(r, 0));

test("form submit with invalid repo shows error", async () => {
const repoInput = document.getElementById("repo");
expect(repoInput.classList.contains("is-invalid")).toBe(false);
repoInput.value = "invalidrepo";
document
.getElementById("generate-form")
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await flushPromises();
expect(repoInput.classList.contains("is-invalid")).toBe(true);
expect(window.navigateTo).not.toHaveBeenCalled();
});

test("form submit with valid slug works", () => {
test("form submit with valid slug works", async () => {
const repoInput = document.getElementById("repo");
repoInput.value = "owner/repo";
document
.getElementById("generate-form")
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await flushPromises();
expect(window.navigateTo).toHaveBeenCalledWith(
expect.stringContaining("https://github.com/owner/repo/new/main?filename="),
);
});

test("form submit with valid URL works", () => {
test("form submit with valid URL works", async () => {
const repoInput = document.getElementById("repo");
repoInput.value = "https://github.com/octocat/Hello-World";
document
.getElementById("generate-form")
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await flushPromises();
expect(window.navigateTo).toHaveBeenCalledWith(
expect.stringContaining(
"https://github.com/octocat/Hello-World/new/main?filename=",
),
);
});

test("form submit with valid cloud-hosted enterprise URL works", () => {
test("form submit with valid cloud-hosted enterprise URL works", async () => {
const repoInput = document.getElementById("repo");
repoInput.value = "https://github.com/enterprises/gh/octocat/Hello-World";
document
.getElementById("generate-form")
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await flushPromises();
expect(window.navigateTo).toHaveBeenCalledWith(
expect.stringContaining(
"https://github.com/enterprises/gh/octocat/Hello-World/new/main?filename=",
),
);
});

test("form submit uses detected default branch", async () => {
jest.resetModules();
jest.unstable_mockModule("../public/scripts/workflow.js", () => ({
parseRepositoryURL: (v) => ({
origin: "https://github.com",
owner: "o",
repo: "r",
}),
detectDefaultBranch: jest.fn().mockResolvedValue("master"),
generateWorkflow: () => "yaml-content",
}));
await import("../public/scripts/main.js");
window.navigateTo = jest.fn();
document
.getElementById("generate-form")
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await flushPromises();
expect(window.navigateTo).toHaveBeenCalledWith(
expect.stringContaining("https://github.com/o/r/new/master?filename="),
);
});

test("form submit defaults to main branch if detection fails", async () => {
jest.resetModules();
jest.unstable_mockModule("../public/scripts/workflow.js", () => ({
parseRepositoryURL: (v) => ({
origin: "https://github.com",
owner: "o",
repo: "r",
}),
detectDefaultBranch: jest.fn().mockResolvedValue(null),
generateWorkflow: () => "yaml-content",
}));
await import("../public/scripts/main.js");
window.navigateTo = jest.fn();
document
.getElementById("generate-form")
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await flushPromises();
expect(window.navigateTo).toHaveBeenCalledWith(
expect.stringContaining("https://github.com/o/r/new/main?filename="),
);
});

test("advanced options are passed to generateWorkflow", async () => {
// Re-import main.js with a spy on generateWorkflow
jest.resetModules();
const workflowSpy = jest.fn(() => "yaml-content");
jest.unstable_mockModule("../public/scripts/workflow.js", () => ({
parseRepositoryURL: (v) => ({ owner: "o", repo: "r" }),
detectDefaultBranch: jest.fn().mockResolvedValue("main"),
generateWorkflow: workflowSpy,
}));
document.getElementById("repo").value = "o/r";
Expand All @@ -89,15 +146,17 @@ test("advanced options are passed to generateWorkflow", async () => {
document
.getElementById("generate-form")
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await flushPromises();
expect(workflowSpy).toHaveBeenCalledWith({
useBatchToken: true,
useVirtualDisplay: false,
buildAcrossPlatforms: true,
siteUrl: "http://localhost",
branch: "main",
});
});

test("download link triggers file download", () => {
test("download link triggers file download", async () => {
const repoInput = document.getElementById("repo");
repoInput.value = "owner/repo";

Expand All @@ -116,6 +175,8 @@ test("download link triggers file download", () => {

document.getElementById("download-alert-link").click();

await flushPromises();

expect(mockCreateObjectURL).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalled();
expect(mockRevokeObjectURL).toHaveBeenCalled();
Expand Down
Loading