chore: import upstream snapshot with attribution
Enforce Pull-Request Rules / check (push) Has been cancelled
Enforce Pull-Request Rules / check (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Updatenotification > MagicMirror on develop > returns status information 1`] = `
|
||||
{
|
||||
"behind": 5,
|
||||
"current": "develop",
|
||||
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
|
||||
"isBehindInStatus": false,
|
||||
"module": "MagicMirror",
|
||||
"tracking": "origin/develop",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Updatenotification > MagicMirror on develop > returns status information early if isBehindInStatus 1`] = `
|
||||
{
|
||||
"behind": 5,
|
||||
"current": "develop",
|
||||
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
|
||||
"isBehindInStatus": true,
|
||||
"module": "MagicMirror",
|
||||
"tracking": "origin/develop",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Updatenotification > MagicMirror on master (empty taglist) > returns status information 1`] = `
|
||||
{
|
||||
"behind": 5,
|
||||
"current": "master",
|
||||
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
|
||||
"isBehindInStatus": false,
|
||||
"module": "MagicMirror",
|
||||
"tracking": "origin/master",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Updatenotification > MagicMirror on master (empty taglist) > returns status information early if isBehindInStatus 1`] = `
|
||||
{
|
||||
"behind": 5,
|
||||
"current": "master",
|
||||
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
|
||||
"isBehindInStatus": true,
|
||||
"module": "MagicMirror",
|
||||
"tracking": "origin/master",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Updatenotification > MagicMirror on master with match in taglist > returns status information 1`] = `
|
||||
{
|
||||
"behind": 5,
|
||||
"current": "master",
|
||||
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
|
||||
"isBehindInStatus": false,
|
||||
"module": "MagicMirror",
|
||||
"tracking": "origin/master",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Updatenotification > MagicMirror on master with match in taglist > returns status information early if isBehindInStatus 1`] = `
|
||||
{
|
||||
"behind": 5,
|
||||
"current": "master",
|
||||
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
|
||||
"isBehindInStatus": true,
|
||||
"module": "MagicMirror",
|
||||
"tracking": "origin/master",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Updatenotification > MagicMirror on master without match in taglist > returns status information 1`] = `
|
||||
{
|
||||
"behind": 0,
|
||||
"current": "master",
|
||||
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
|
||||
"isBehindInStatus": false,
|
||||
"module": "MagicMirror",
|
||||
"tracking": "origin/master",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Updatenotification > MagicMirror on master without match in taglist > returns status information early if isBehindInStatus 1`] = `
|
||||
{
|
||||
"behind": 0,
|
||||
"current": "master",
|
||||
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
|
||||
"isBehindInStatus": true,
|
||||
"module": "MagicMirror",
|
||||
"tracking": "origin/master",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Updatenotification > custom module > returns status information without hash 1`] = `
|
||||
{
|
||||
"behind": 7,
|
||||
"current": "master",
|
||||
"hash": "",
|
||||
"isBehindInStatus": false,
|
||||
"module": "MMM-Fuel",
|
||||
"tracking": "origin/master",
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,71 @@
|
||||
const path = require("node:path");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
|
||||
describe("Test function cmpVersions in js/module.js", () => {
|
||||
let cmp;
|
||||
let originalWindow;
|
||||
let originalLog;
|
||||
let originalConfig;
|
||||
let originalMM;
|
||||
let originalTranslator;
|
||||
let originalNunjucks;
|
||||
|
||||
beforeAll(async () => {
|
||||
originalWindow = global.window;
|
||||
originalLog = global.Log;
|
||||
originalConfig = global.config;
|
||||
originalMM = global.MM;
|
||||
originalTranslator = global.Translator;
|
||||
originalNunjucks = global.nunjucks;
|
||||
|
||||
global.window = { mmVersion: "2.0.0" };
|
||||
global.Log = { log: () => {}, info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
|
||||
global.config = { language: "en" };
|
||||
global.MM = {
|
||||
hideModule: () => {},
|
||||
showModule: () => {},
|
||||
sendNotification: () => {},
|
||||
updateDom: () => {}
|
||||
};
|
||||
global.Translator = {
|
||||
load: () => Promise.resolve(),
|
||||
translate: () => ""
|
||||
};
|
||||
global.nunjucks = {
|
||||
Environment () {
|
||||
this.addFilter = () => {};
|
||||
this.renderString = () => "";
|
||||
this.render = (_template, _data, callback) => callback(null, "");
|
||||
},
|
||||
WebLoader () {},
|
||||
runtime: {
|
||||
markSafe: (str) => str
|
||||
}
|
||||
};
|
||||
|
||||
const modulePath = pathToFileURL(path.join(__dirname, "..", "..", "..", "js", "module.js")).href;
|
||||
const loaded = await import(`${modulePath}?test=${Date.now()}`);
|
||||
cmp = loaded.cmpVersions;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
global.window = originalWindow;
|
||||
global.Log = originalLog;
|
||||
global.config = originalConfig;
|
||||
global.MM = originalMM;
|
||||
global.Translator = originalTranslator;
|
||||
global.nunjucks = originalNunjucks;
|
||||
});
|
||||
|
||||
it("should return -1 when comparing 2.1 to 2.2", () => {
|
||||
expect(cmp("2.1", "2.2")).toBe(-1);
|
||||
});
|
||||
|
||||
it("should be return 0 when comparing 2.2 to 2.2", () => {
|
||||
expect(cmp("2.2", "2.2")).toBe(0);
|
||||
});
|
||||
|
||||
it("should be return 1 when comparing 1.1 to 1.0", () => {
|
||||
expect(cmp("1.1", "1.0")).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
const Log = require("logger");
|
||||
const { applyElectronSwitches } = require("../../../js/electron_helper");
|
||||
|
||||
describe("electron switches", () => {
|
||||
let commandLine;
|
||||
|
||||
beforeEach(() => {
|
||||
commandLine = {
|
||||
appendSwitch: vi.fn()
|
||||
};
|
||||
vi.spyOn(Log, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("does nothing when electronSwitches is undefined", () => {
|
||||
applyElectronSwitches(commandLine, undefined);
|
||||
|
||||
expect(commandLine.appendSwitch).not.toHaveBeenCalled();
|
||||
expect(Log.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies string entries as switches without values", () => {
|
||||
applyElectronSwitches(commandLine, ["no-sandbox", "disable-http-cache"]);
|
||||
|
||||
expect(commandLine.appendSwitch).toHaveBeenCalledTimes(2);
|
||||
expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(1, "no-sandbox");
|
||||
expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(2, "disable-http-cache");
|
||||
expect(Log.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies object entries as switches with values", () => {
|
||||
applyElectronSwitches(commandLine, [
|
||||
{ "js-flags": "--max-old-space-size=8192" },
|
||||
{ "password-store": "basic" }
|
||||
]);
|
||||
|
||||
expect(commandLine.appendSwitch).toHaveBeenCalledTimes(2);
|
||||
expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(1, "js-flags", "--max-old-space-size=8192");
|
||||
expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(2, "password-store", "basic");
|
||||
expect(Log.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows one object entry to define multiple switches with values", () => {
|
||||
applyElectronSwitches(commandLine, [
|
||||
"no-sandbox",
|
||||
{
|
||||
"js-flags": "--max-old-space-size=8192",
|
||||
"password-store": "basic"
|
||||
}
|
||||
]);
|
||||
|
||||
expect(commandLine.appendSwitch).toHaveBeenCalledTimes(3);
|
||||
expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(1, "no-sandbox");
|
||||
expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(2, "js-flags", "--max-old-space-size=8192");
|
||||
expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(3, "password-store", "basic");
|
||||
expect(Log.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs an error for invalid entries", () => {
|
||||
applyElectronSwitches(commandLine, ["no-sandbox", ["js-flags", "--max-old-space-size=8192"], null]);
|
||||
|
||||
expect(commandLine.appendSwitch).toHaveBeenCalledTimes(1);
|
||||
expect(commandLine.appendSwitch).toHaveBeenCalledWith("no-sandbox");
|
||||
expect(Log.error).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("logs an error when electronSwitches is not an array", () => {
|
||||
applyElectronSwitches(commandLine, { "js-flags": "--max-old-space-size=8192" });
|
||||
|
||||
expect(commandLine.appendSwitch).not.toHaveBeenCalled();
|
||||
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining("electronSwitches must be an array of strings or objects"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,579 @@
|
||||
const { http, HttpResponse } = require("msw");
|
||||
const { setupServer } = require("msw/node");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
const TEST_URL = "http://test.example.com/data";
|
||||
let server;
|
||||
let fetcher;
|
||||
|
||||
beforeAll(() => {
|
||||
server = setupServer();
|
||||
server.listen({ onUnhandledRequest: "error" });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
if (fetcher) {
|
||||
fetcher.clearTimer();
|
||||
fetcher = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("HTTPFetcher", () => {
|
||||
|
||||
describe("Basic fetch operations", () => {
|
||||
it("should emit response event on successful fetch", async () => {
|
||||
const responseData = "test data";
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return HttpResponse.text(responseData);
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
||||
|
||||
const responsePromise = new Promise((resolve) => {
|
||||
fetcher.on("response", (response) => {
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
const text = await response.text();
|
||||
expect(text).toBe(responseData);
|
||||
});
|
||||
|
||||
it("should emit error event on network failure", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return HttpResponse.error();
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
fetcher.on("error", (errorInfo) => {
|
||||
resolve(errorInfo);
|
||||
});
|
||||
});
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
const errorInfo = await errorPromise;
|
||||
|
||||
expect(errorInfo).toHaveProperty("errorType", "NETWORK_ERROR");
|
||||
expect(errorInfo).toHaveProperty("translationKey", "MODULE_ERROR_NO_CONNECTION");
|
||||
expect(errorInfo).toHaveProperty("url", TEST_URL);
|
||||
});
|
||||
|
||||
it("should emit error event on timeout", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, async () => {
|
||||
// Simulate a slow server that never responds
|
||||
await new Promise((resolve) => setTimeout(resolve, 60000));
|
||||
return HttpResponse.text("too late");
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000, timeout: 100 });
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
fetcher.on("error", (errorInfo) => {
|
||||
resolve(errorInfo);
|
||||
});
|
||||
});
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
const errorInfo = await errorPromise;
|
||||
|
||||
expect(errorInfo.errorType).toBe("NETWORK_ERROR");
|
||||
expect(errorInfo.message).toContain("timeout");
|
||||
expect(errorInfo.message).toContain("100ms");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTPFetcher - HTTP status code handling", () => {
|
||||
describe("304 Not Modified", () => {
|
||||
it("should emit response event for 304 and not emit error", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return new HttpResponse(null, { status: 304 });
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
||||
fetcher.serverErrorCount = 2;
|
||||
fetcher.networkErrorCount = 3;
|
||||
|
||||
const responsePromise = new Promise((resolve) => {
|
||||
fetcher.on("response", resolve);
|
||||
});
|
||||
const errorSpy = vi.fn();
|
||||
fetcher.on("error", errorSpy);
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(response.status).toBe(304);
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
expect(fetcher.serverErrorCount).toBe(0);
|
||||
expect(fetcher.networkErrorCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("401/403 errors (Auth failures)", () => {
|
||||
it("should emit error with AUTH_FAILURE for 401", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return new HttpResponse(null, { status: 401 });
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
fetcher.on("error", (errorInfo) => {
|
||||
resolve(errorInfo);
|
||||
});
|
||||
});
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
const errorInfo = await errorPromise;
|
||||
|
||||
expect(errorInfo.status).toBe(401);
|
||||
expect(errorInfo.errorType).toBe("AUTH_FAILURE");
|
||||
expect(errorInfo.translationKey).toBe("MODULE_ERROR_UNAUTHORIZED");
|
||||
});
|
||||
|
||||
it("should emit error with AUTH_FAILURE for 403", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return new HttpResponse(null, { status: 403 });
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
fetcher.on("error", (errorInfo) => {
|
||||
resolve(errorInfo);
|
||||
});
|
||||
});
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
const errorInfo = await errorPromise;
|
||||
|
||||
expect(errorInfo.status).toBe(403);
|
||||
expect(errorInfo.errorType).toBe("AUTH_FAILURE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("429 errors (Rate limiting)", () => {
|
||||
it("should emit error with RATE_LIMITED for 429", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return new HttpResponse(null, {
|
||||
status: 429,
|
||||
headers: { "Retry-After": "120" }
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
fetcher.on("error", (errorInfo) => {
|
||||
resolve(errorInfo);
|
||||
});
|
||||
});
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
const errorInfo = await errorPromise;
|
||||
|
||||
expect(errorInfo.status).toBe(429);
|
||||
expect(errorInfo.errorType).toBe("RATE_LIMITED");
|
||||
expect(errorInfo.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should parse Retry-After header in seconds", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return new HttpResponse(null, {
|
||||
status: 429,
|
||||
headers: { "Retry-After": "300" }
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
fetcher.on("error", (errorInfo) => {
|
||||
resolve(errorInfo);
|
||||
});
|
||||
});
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
const errorInfo = await errorPromise;
|
||||
|
||||
// 300 seconds = 300000 ms
|
||||
expect(errorInfo.retryAfter).toBe(300000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("5xx errors (Server errors)", () => {
|
||||
it("should emit error with SERVER_ERROR for 500", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return new HttpResponse(null, { status: 500 });
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
fetcher.on("error", (errorInfo) => {
|
||||
resolve(errorInfo);
|
||||
});
|
||||
});
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
const errorInfo = await errorPromise;
|
||||
|
||||
expect(errorInfo.status).toBe(500);
|
||||
expect(errorInfo.errorType).toBe("SERVER_ERROR");
|
||||
});
|
||||
|
||||
it("should emit error with SERVER_ERROR for 503", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return new HttpResponse(null, { status: 503 });
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
fetcher.on("error", (errorInfo) => {
|
||||
resolve(errorInfo);
|
||||
});
|
||||
});
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
const errorInfo = await errorPromise;
|
||||
|
||||
expect(errorInfo.status).toBe(503);
|
||||
expect(errorInfo.errorType).toBe("SERVER_ERROR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("4xx errors (Client errors)", () => {
|
||||
it("should emit error with CLIENT_ERROR for 404", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return new HttpResponse(null, { status: 404 });
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
fetcher.on("error", (errorInfo) => {
|
||||
resolve(errorInfo);
|
||||
});
|
||||
});
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
const errorInfo = await errorPromise;
|
||||
|
||||
expect(errorInfo.status).toBe(404);
|
||||
expect(errorInfo.errorType).toBe("CLIENT_ERROR");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTPFetcher - Authentication", () => {
|
||||
it("should include Basic auth header when configured", async () => {
|
||||
let receivedHeaders = null;
|
||||
|
||||
server.use(
|
||||
http.get(TEST_URL, ({ request }) => {
|
||||
receivedHeaders = Object.fromEntries(request.headers);
|
||||
return HttpResponse.text("ok");
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, {
|
||||
reloadInterval: 60000,
|
||||
auth: {
|
||||
method: "basic",
|
||||
user: "testuser",
|
||||
pass: "testpass"
|
||||
}
|
||||
});
|
||||
|
||||
const responsePromise = new Promise((resolve) => {
|
||||
fetcher.on("response", resolve);
|
||||
});
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
await responsePromise;
|
||||
|
||||
const expectedAuth = `Basic ${Buffer.from("testuser:testpass").toString("base64")}`;
|
||||
expect(receivedHeaders.authorization).toBe(expectedAuth);
|
||||
});
|
||||
|
||||
it("should include Bearer auth header when configured", async () => {
|
||||
let receivedHeaders = null;
|
||||
|
||||
server.use(
|
||||
http.get(TEST_URL, ({ request }) => {
|
||||
receivedHeaders = Object.fromEntries(request.headers);
|
||||
return HttpResponse.text("ok");
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, {
|
||||
reloadInterval: 60000,
|
||||
auth: {
|
||||
method: "bearer",
|
||||
pass: "my-token-123"
|
||||
}
|
||||
});
|
||||
|
||||
const responsePromise = new Promise((resolve) => {
|
||||
fetcher.on("response", resolve);
|
||||
});
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
await responsePromise;
|
||||
|
||||
expect(receivedHeaders.authorization).toBe("Bearer my-token-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Custom headers", () => {
|
||||
it("should include custom headers in request", async () => {
|
||||
let receivedHeaders = null;
|
||||
|
||||
server.use(
|
||||
http.get(TEST_URL, ({ request }) => {
|
||||
receivedHeaders = Object.fromEntries(request.headers);
|
||||
return HttpResponse.text("ok");
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, {
|
||||
reloadInterval: 60000,
|
||||
headers: {
|
||||
"X-Custom-Header": "custom-value",
|
||||
Accept: "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
const responsePromise = new Promise((resolve) => {
|
||||
fetcher.on("response", resolve);
|
||||
});
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
await responsePromise;
|
||||
|
||||
expect(receivedHeaders["x-custom-header"]).toBe("custom-value");
|
||||
expect(receivedHeaders.accept).toBe("application/json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Timer management", () => {
|
||||
it("should not set timer in test mode", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return HttpResponse.text("ok");
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 100 });
|
||||
|
||||
const responsePromise = new Promise((resolve) => {
|
||||
fetcher.on("response", resolve);
|
||||
});
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
await responsePromise;
|
||||
|
||||
// Timer should NOT be set in test mode (mmTestMode=true)
|
||||
expect(fetcher.reloadTimer).toBeNull();
|
||||
});
|
||||
|
||||
it("should clear timer when clearTimer is called", () => {
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 100 });
|
||||
|
||||
// Manually set a timer to test clearing
|
||||
fetcher.reloadTimer = setTimeout(() => {}, 10000);
|
||||
expect(fetcher.reloadTimer).not.toBeNull();
|
||||
|
||||
fetcher.clearTimer();
|
||||
|
||||
expect(fetcher.reloadTimer).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch() method", () => {
|
||||
it("should emit response event when called", async () => {
|
||||
const responseData = "direct fetch data";
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return HttpResponse.text(responseData);
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
||||
|
||||
const responsePromise = new Promise((resolve) => {
|
||||
fetcher.on("response", resolve);
|
||||
});
|
||||
|
||||
await fetcher.fetch();
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const text = await response.text();
|
||||
expect(text).toBe(responseData);
|
||||
});
|
||||
|
||||
it("should emit error event on network error", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return HttpResponse.error();
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
||||
|
||||
const errorPromise = new Promise((resolve) => {
|
||||
fetcher.on("error", resolve);
|
||||
});
|
||||
|
||||
await fetcher.fetch();
|
||||
const errorInfo = await errorPromise;
|
||||
|
||||
expect(errorInfo.errorType).toBe("NETWORK_ERROR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selfSignedCert dispatcher", () => {
|
||||
const { Agent } = require("undici");
|
||||
|
||||
it("should set rejectUnauthorized=false when selfSignedCert is true", () => {
|
||||
fetcher = new HTTPFetcher(TEST_URL, {
|
||||
reloadInterval: 60000,
|
||||
selfSignedCert: true
|
||||
});
|
||||
|
||||
const options = fetcher.getRequestOptions();
|
||||
|
||||
expect(options.dispatcher).toBeInstanceOf(Agent);
|
||||
const agentOptionsSymbol = Object.getOwnPropertySymbols(options.dispatcher).find((s) => s.description === "options");
|
||||
const dispatcherOptions = options.dispatcher[agentOptionsSymbol];
|
||||
expect(dispatcherOptions.connect.rejectUnauthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("should not set a dispatcher when selfSignedCert is false", () => {
|
||||
fetcher = new HTTPFetcher(TEST_URL, {
|
||||
reloadInterval: 60000,
|
||||
selfSignedCert: false
|
||||
});
|
||||
|
||||
const options = fetcher.getRequestOptions();
|
||||
|
||||
expect(options.dispatcher).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Retry exhaustion fallback", () => {
|
||||
it("should fall back to reloadInterval after network retries exhausted", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return HttpResponse.error();
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 300000, maxRetries: 3 });
|
||||
|
||||
const errors = [];
|
||||
fetcher.on("error", (errorInfo) => errors.push(errorInfo));
|
||||
|
||||
// Trigger maxRetries + 1 fetches to reach exhaustion
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await fetcher.fetch();
|
||||
}
|
||||
|
||||
// First retries should use backoff (< reloadInterval)
|
||||
expect(errors[0].retryAfter).toBe(15000);
|
||||
expect(errors[1].retryAfter).toBe(30000);
|
||||
// Third retry hits maxRetries, should fall back to reloadInterval
|
||||
expect(errors[2].retryAfter).toBe(300000);
|
||||
// Subsequent errors stay at reloadInterval
|
||||
expect(errors[3].retryAfter).toBe(300000);
|
||||
});
|
||||
|
||||
it("should fall back to reloadInterval after server error retries exhausted", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return new HttpResponse(null, { status: 503 });
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 300000, maxRetries: 3 });
|
||||
|
||||
const errors = [];
|
||||
fetcher.on("error", (errorInfo) => errors.push(errorInfo));
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await fetcher.fetch();
|
||||
}
|
||||
|
||||
// First retries should use backoff (< reloadInterval)
|
||||
expect(errors[0].retryAfter).toBe(15000);
|
||||
expect(errors[1].retryAfter).toBe(30000);
|
||||
// Third retry hits maxRetries, should fall back to reloadInterval
|
||||
expect(errors[2].retryAfter).toBe(300000);
|
||||
// Subsequent errors stay at reloadInterval
|
||||
expect(errors[3].retryAfter).toBe(300000);
|
||||
});
|
||||
|
||||
it("should reset network error count on success", async () => {
|
||||
let requestCount = 0;
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
requestCount++;
|
||||
if (requestCount <= 2) return HttpResponse.error();
|
||||
return HttpResponse.text("ok");
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 300000, maxRetries: 3 });
|
||||
|
||||
const errors = [];
|
||||
fetcher.on("error", (errorInfo) => errors.push(errorInfo));
|
||||
|
||||
// Two failures with backoff
|
||||
await fetcher.fetch();
|
||||
await fetcher.fetch();
|
||||
expect(errors).toHaveLength(2);
|
||||
expect(errors[0].retryAfter).toBe(15000);
|
||||
expect(errors[1].retryAfter).toBe(30000);
|
||||
|
||||
// Success resets counter
|
||||
await fetcher.fetch();
|
||||
expect(fetcher.networkErrorCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ipAccessControl, socketIpAccessControl } from "../../../js/ip_access_control";
|
||||
|
||||
/**
|
||||
* Creates a minimal Express-like response mock used by the middleware tests.
|
||||
* @returns {{ header: ReturnType<typeof vi.fn>, status: ReturnType<typeof vi.fn>, send: ReturnType<typeof vi.fn> }} Mock response object.
|
||||
*/
|
||||
function createResponseMock () {
|
||||
return {
|
||||
header: vi.fn(),
|
||||
status: vi.fn(function () {
|
||||
return this;
|
||||
}),
|
||||
send: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
describe("ip_access_control", () => {
|
||||
describe("ipAccessControl", () => {
|
||||
it("trusts first X-Forwarded-For entry when direct peer is loopback", () => {
|
||||
const middleware = ipAccessControl(["203.0.113.10"]);
|
||||
const req = {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: { "x-forwarded-for": "203.0.113.10, 10.0.0.2" }
|
||||
};
|
||||
const res = createResponseMock();
|
||||
const next = vi.fn();
|
||||
|
||||
middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores X-Forwarded-For when direct peer is not loopback", () => {
|
||||
const middleware = ipAccessControl(["203.0.113.10"]);
|
||||
const req = {
|
||||
socket: { remoteAddress: "198.51.100.7" },
|
||||
headers: { "x-forwarded-for": "203.0.113.10" }
|
||||
};
|
||||
const res = createResponseMock();
|
||||
const next = vi.fn();
|
||||
|
||||
middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("socketIpAccessControl", () => {
|
||||
it("accepts socket handshake using forwarded client IP when direct peer is loopback", () => {
|
||||
const allowRequest = socketIpAccessControl(["203.0.113.10"]);
|
||||
const req = {
|
||||
socket: { remoteAddress: "::1" },
|
||||
headers: { "x-forwarded-for": "203.0.113.10, 10.0.0.2" }
|
||||
};
|
||||
const callback = vi.fn();
|
||||
|
||||
allowRequest(req, callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(null, true);
|
||||
});
|
||||
|
||||
it("rejects socket handshake when only forwarded IP matches whitelist", () => {
|
||||
const allowRequest = socketIpAccessControl(["203.0.113.10"]);
|
||||
const req = {
|
||||
socket: { remoteAddress: "198.51.100.7" },
|
||||
headers: { "x-forwarded-for": "203.0.113.10" }
|
||||
};
|
||||
const callback = vi.fn();
|
||||
|
||||
allowRequest(req, callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith("This device is not allowed to access your mirror.", false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
// Tests use vi.spyOn on shared module objects (dns, undici).
|
||||
// vi.spyOn modifies the object property directly on the cached module instance, so it
|
||||
// is intercepted by server_functions.js regardless of the Module.prototype.require override
|
||||
// in vitest-setup.js. restoreAllMocks:true auto-restores spies, but may reuse the same
|
||||
// spy instance — mockClear() is called explicitly in beforeEach to reset call history.
|
||||
const dns = require("node:dns");
|
||||
const undici = require("undici");
|
||||
const { cors, getUserAgent, replaceSecretPlaceholder } = require("#server_functions");
|
||||
|
||||
describe("server_functions tests", () => {
|
||||
describe("The replaceSecretPlaceholder method with cors=allowWhitelist", () => {
|
||||
beforeEach(() => {
|
||||
global.config = { cors: "allowWhitelist" };
|
||||
});
|
||||
|
||||
it("Calls string without secret placeholder", () => {
|
||||
const teststring = "test string without secret placeholder";
|
||||
const result = replaceSecretPlaceholder(teststring);
|
||||
expect(result).toBe(teststring);
|
||||
});
|
||||
|
||||
it("Calls string with 2 secret placeholders", () => {
|
||||
const teststring = "test string with secret1=**SECRET_ONE** and secret2=**SECRET_TWO**";
|
||||
process.env.SECRET_ONE = "secret1";
|
||||
process.env.SECRET_TWO = "secret2";
|
||||
const resultstring = `test string with secret1=${process.env.SECRET_ONE} and secret2=${process.env.SECRET_TWO}`;
|
||||
const result = replaceSecretPlaceholder(teststring);
|
||||
expect(result).toBe(resultstring);
|
||||
});
|
||||
});
|
||||
|
||||
describe("The replaceSecretPlaceholder method with cors=allowAll", () => {
|
||||
beforeEach(() => {
|
||||
global.config = { cors: "allowAll" };
|
||||
});
|
||||
|
||||
it("Calls string without secret placeholder", () => {
|
||||
const teststring = "test string without secret placeholder";
|
||||
const result = replaceSecretPlaceholder(teststring);
|
||||
expect(result).toBe(teststring);
|
||||
});
|
||||
|
||||
it("Calls string with 2 secret placeholders", () => {
|
||||
const teststring = "test string with secret1=**SECRET_ONE** and secret2=**SECRET_TWO**";
|
||||
const result = replaceSecretPlaceholder(teststring);
|
||||
expect(result).toBe(teststring);
|
||||
});
|
||||
});
|
||||
|
||||
describe("The replaceSecretPlaceholder method with an allowedSecrets set", () => {
|
||||
beforeEach(() => {
|
||||
global.config = { cors: "allowWhitelist" };
|
||||
process.env.SECRET_ALLOWED = "allowed-value";
|
||||
process.env.SECRET_DENIED = "denied-value";
|
||||
});
|
||||
|
||||
it("Restores only allowed secrets and keeps denied placeholders untouched", () => {
|
||||
const teststring = "allowed=**SECRET_ALLOWED** denied=**SECRET_DENIED**";
|
||||
const result = replaceSecretPlaceholder(teststring, new Set(["SECRET_ALLOWED"]));
|
||||
expect(result).toBe("allowed=allowed-value denied=**SECRET_DENIED**");
|
||||
expect(result).not.toContain("denied-value");
|
||||
});
|
||||
|
||||
it("Does not restore any placeholder when the set is empty", () => {
|
||||
const teststring = "value=**SECRET_ALLOWED**";
|
||||
const result = replaceSecretPlaceholder(teststring, new Set());
|
||||
expect(result).toBe(teststring);
|
||||
});
|
||||
|
||||
it("Falls back to the placeholder if the allowed secret doesn't exist in environment", () => {
|
||||
const teststring = "value=**SECRET_MISSING**";
|
||||
const result = replaceSecretPlaceholder(teststring, new Set(["SECRET_MISSING"]));
|
||||
expect(result).toBe(teststring);
|
||||
});
|
||||
});
|
||||
|
||||
describe("The cors method", () => {
|
||||
let fetchSpy;
|
||||
let fetchResponseHeadersGet;
|
||||
let fetchResponseArrayBuffer;
|
||||
let corsResponse;
|
||||
let request;
|
||||
|
||||
beforeEach(() => {
|
||||
global.config = { cors: "allowAll" };
|
||||
fetchResponseHeadersGet = vi.fn(() => {});
|
||||
fetchResponseArrayBuffer = vi.fn(() => {});
|
||||
|
||||
// Mock DNS to return a public IP (SSRF check must pass for these tests)
|
||||
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 });
|
||||
|
||||
// vi.spyOn may return the same spy instance across tests when restoreAllMocks
|
||||
// restores-but-reuses; mockClear() explicitly resets call history each time.
|
||||
fetchSpy = vi.spyOn(undici, "fetch");
|
||||
fetchSpy.mockClear();
|
||||
fetchSpy.mockImplementation(() => Promise.resolve({
|
||||
headers: { get: fetchResponseHeadersGet },
|
||||
arrayBuffer: fetchResponseArrayBuffer,
|
||||
ok: true
|
||||
}));
|
||||
|
||||
corsResponse = {
|
||||
set: vi.fn(() => {}),
|
||||
send: vi.fn(() => {}),
|
||||
status: vi.fn(function (code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
}),
|
||||
json: vi.fn(() => {})
|
||||
};
|
||||
|
||||
request = {
|
||||
url: "/cors?url=http://www.test.com"
|
||||
};
|
||||
});
|
||||
|
||||
it("Calls correct URL once", async () => {
|
||||
const urlToCall = "http://www.test.com/path?param1=value1";
|
||||
request.url = `/cors?url=${urlToCall}`;
|
||||
|
||||
await cors(request, corsResponse);
|
||||
|
||||
expect(fetchSpy.mock.calls).toHaveLength(1);
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe(urlToCall);
|
||||
});
|
||||
|
||||
it("Forwards Content-Type if json", async () => {
|
||||
fetchResponseHeadersGet.mockImplementation(() => "json");
|
||||
|
||||
await cors(request, corsResponse);
|
||||
|
||||
expect(fetchResponseHeadersGet.mock.calls).toHaveLength(1);
|
||||
expect(fetchResponseHeadersGet.mock.calls[0][0]).toBe("Content-Type");
|
||||
|
||||
expect(corsResponse.set.mock.calls).toHaveLength(1);
|
||||
expect(corsResponse.set.mock.calls[0][0]).toBe("Content-Type");
|
||||
expect(corsResponse.set.mock.calls[0][1]).toBe("json");
|
||||
});
|
||||
|
||||
it("Forwards Content-Type if xml", async () => {
|
||||
fetchResponseHeadersGet.mockImplementation(() => "xml");
|
||||
|
||||
await cors(request, corsResponse);
|
||||
|
||||
expect(fetchResponseHeadersGet.mock.calls).toHaveLength(1);
|
||||
expect(fetchResponseHeadersGet.mock.calls[0][0]).toBe("Content-Type");
|
||||
|
||||
expect(corsResponse.set.mock.calls).toHaveLength(1);
|
||||
expect(corsResponse.set.mock.calls[0][0]).toBe("Content-Type");
|
||||
expect(corsResponse.set.mock.calls[0][1]).toBe("xml");
|
||||
});
|
||||
|
||||
it("Sends correct data from response", async () => {
|
||||
const responseData = "some data";
|
||||
const encoder = new TextEncoder();
|
||||
const arrayBuffer = encoder.encode(responseData).buffer;
|
||||
fetchResponseArrayBuffer.mockImplementation(() => arrayBuffer);
|
||||
|
||||
let sentData;
|
||||
corsResponse.send = vi.fn((input) => {
|
||||
sentData = input;
|
||||
});
|
||||
|
||||
await cors(request, corsResponse);
|
||||
|
||||
expect(fetchResponseArrayBuffer.mock.calls).toHaveLength(1);
|
||||
expect(sentData).toEqual(Buffer.from(arrayBuffer));
|
||||
});
|
||||
|
||||
it("Sends error data from response", async () => {
|
||||
const error = new Error("error data");
|
||||
fetchResponseArrayBuffer.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
await cors(request, corsResponse);
|
||||
|
||||
expect(fetchResponseArrayBuffer.mock.calls).toHaveLength(1);
|
||||
expect(corsResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(corsResponse.json).toHaveBeenCalledWith({ error: error.message });
|
||||
});
|
||||
|
||||
it("Fetches with user agent by default", async () => {
|
||||
await cors(request, corsResponse);
|
||||
|
||||
expect(fetchSpy.mock.calls).toHaveLength(1);
|
||||
expect(fetchSpy.mock.calls[0][1]).toHaveProperty("headers");
|
||||
expect(fetchSpy.mock.calls[0][1].headers).toHaveProperty("User-Agent");
|
||||
});
|
||||
|
||||
it("Fetches with specified headers", async () => {
|
||||
const headersParam = "sendheaders=header1:value1,header2:value2";
|
||||
const urlParam = "http://www.test.com/path?param1=value1";
|
||||
request.url = `/cors?${headersParam}&url=${urlParam}`;
|
||||
|
||||
await cors(request, corsResponse);
|
||||
|
||||
expect(fetchSpy.mock.calls).toHaveLength(1);
|
||||
expect(fetchSpy.mock.calls[0][1]).toHaveProperty("headers");
|
||||
expect(fetchSpy.mock.calls[0][1].headers).toHaveProperty("header1", "value1");
|
||||
expect(fetchSpy.mock.calls[0][1].headers).toHaveProperty("header2", "value2");
|
||||
});
|
||||
|
||||
it("Sends specified headers", async () => {
|
||||
fetchResponseHeadersGet.mockImplementation((input) => input.replace("header", "value"));
|
||||
|
||||
const expectedheaders = "expectedheaders=header1,header2";
|
||||
const urlParam = "http://www.test.com/path?param1=value1";
|
||||
request.url = `/cors?${expectedheaders}&url=${urlParam}`;
|
||||
|
||||
await cors(request, corsResponse);
|
||||
|
||||
expect(fetchSpy.mock.calls).toHaveLength(1);
|
||||
expect(fetchSpy.mock.calls[0][1]).toHaveProperty("headers");
|
||||
expect(corsResponse.set.mock.calls).toHaveLength(3);
|
||||
expect(corsResponse.set.mock.calls[0][0]).toBe("Content-Type");
|
||||
expect(corsResponse.set.mock.calls[1][0]).toBe("header1");
|
||||
expect(corsResponse.set.mock.calls[1][1]).toBe("value1");
|
||||
expect(corsResponse.set.mock.calls[2][0]).toBe("header2");
|
||||
expect(corsResponse.set.mock.calls[2][1]).toBe("value2");
|
||||
});
|
||||
|
||||
it("Gets User-Agent from configuration", () => {
|
||||
const previousConfig = global.config;
|
||||
global.config = {};
|
||||
let userAgent;
|
||||
|
||||
userAgent = getUserAgent();
|
||||
expect(userAgent).toContain("Mozilla/5.0 (Node.js ");
|
||||
|
||||
global.config.userAgent = "Mozilla/5.0 (Foo)";
|
||||
userAgent = getUserAgent();
|
||||
expect(userAgent).toBe("Mozilla/5.0 (Foo)");
|
||||
|
||||
global.config.userAgent = () => "Mozilla/5.0 (Bar)";
|
||||
userAgent = getUserAgent();
|
||||
expect(userAgent).toBe("Mozilla/5.0 (Bar)");
|
||||
|
||||
global.config = previousConfig;
|
||||
});
|
||||
});
|
||||
|
||||
describe("The cors method blocks SSRF (DNS rebinding safe)", () => {
|
||||
let response;
|
||||
|
||||
beforeEach(() => {
|
||||
response = {
|
||||
set: vi.fn(),
|
||||
send: vi.fn(),
|
||||
status: vi.fn(function () { return this; }),
|
||||
json: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
it("Blocks localhost hostname without DNS", async () => {
|
||||
await cors({ url: "/cors?url=http://localhost/path" }, response);
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
expect(response.json).toHaveBeenCalledWith({ error: "Forbidden: private or reserved addresses are not allowed" });
|
||||
});
|
||||
|
||||
it("Blocks non-http protocols", async () => {
|
||||
await cors({ url: "/cors?url=ftp://example.com/file" }, response);
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it("Blocks invalid URLs", async () => {
|
||||
await cors({ url: "/cors?url=not_a_valid_url" }, response);
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it("Blocks loopback addresses (127.0.0.1)", async () => {
|
||||
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "127.0.0.1", family: 4 });
|
||||
await cors({ url: "/cors?url=http://example.com/" }, response);
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it("Blocks RFC 1918 private addresses (192.168.x.x)", async () => {
|
||||
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "192.168.1.1", family: 4 });
|
||||
await cors({ url: "/cors?url=http://example.com/" }, response);
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it("Blocks link-local / cloud metadata addresses (169.254.169.254)", async () => {
|
||||
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "169.254.169.254", family: 4 });
|
||||
await cors({ url: "/cors?url=http://example.com/" }, response);
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it("Allows public unicast addresses", async () => {
|
||||
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 });
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
headers: { get: vi.fn() },
|
||||
arrayBuffer: vi.fn(() => new ArrayBuffer(0))
|
||||
});
|
||||
await cors({ url: "/cors?url=http://example.com/" }, response);
|
||||
expect(response.status).not.toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cors method with allowWhitelist", () => {
|
||||
let response;
|
||||
|
||||
beforeEach(() => {
|
||||
response = {
|
||||
set: vi.fn(),
|
||||
send: vi.fn(),
|
||||
status: vi.fn(function () { return this; }),
|
||||
json: vi.fn()
|
||||
};
|
||||
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 });
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
headers: { get: vi.fn() },
|
||||
arrayBuffer: vi.fn(() => new ArrayBuffer(0))
|
||||
});
|
||||
});
|
||||
|
||||
it("Blocks domains not in whitelist", async () => {
|
||||
global.config = { cors: "allowWhitelist", corsDomainWhitelist: [] };
|
||||
await cors({ url: "/cors?url=http://example.com/api" }, response);
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it("Allows domains in whitelist", async () => {
|
||||
global.config = { cors: "allowWhitelist", corsDomainWhitelist: ["example.com"] };
|
||||
await cors({ url: "/cors?url=http://example.com/api" }, response);
|
||||
expect(response.status).not.toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("UpdateHelper", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const tempRoots = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
global.version = "test";
|
||||
global.root_path = process.cwd();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
for (const tempRoot of tempRoots) {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a temporary MagicMirror-like root with a module directory.
|
||||
* @param {string} moduleName - Name of the module directory to create.
|
||||
* @returns {{ root: string, modulePath: string }} Created paths.
|
||||
*/
|
||||
function createTempModuleRoot (moduleName) {
|
||||
const root = mkdtempSync(join(tmpdir(), "mm-updater-"));
|
||||
const modulePath = join(root, "modules", moduleName);
|
||||
mkdirSync(modulePath, { recursive: true });
|
||||
tempRoots.push(root);
|
||||
return { root, modulePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fresh UpdateHelper instance for testing.
|
||||
* @param {object} config - Optional config overrides.
|
||||
* @returns {Promise<object>} Resolved UpdateHelper instance.
|
||||
*/
|
||||
async function createUpdater (config = {}) {
|
||||
const updateHelperModule = await import("../../../defaultmodules/updatenotification/update_helper");
|
||||
const UpdateHelper = updateHelperModule.default || updateHelperModule;
|
||||
return new UpdateHelper({ updates: [], updateTimeout: 1000, updateAutorestart: false, ...config });
|
||||
}
|
||||
|
||||
it("marks update as requiring manual restart when autoRestart is disabled", async () => {
|
||||
const moduleName = "MMM-Test";
|
||||
const { root } = createTempModuleRoot(moduleName);
|
||||
global.root_path = root;
|
||||
|
||||
const updater = await createUpdater({ updateAutorestart: false });
|
||||
const result = await updater.updateProcess({
|
||||
name: moduleName,
|
||||
updateCommand: `"${process.execPath}" -p 1`
|
||||
});
|
||||
|
||||
expect(result.error).toBe(false);
|
||||
expect(result.updated).toBe(true);
|
||||
expect(result.needRestart).toBe(true);
|
||||
});
|
||||
|
||||
it("schedules node restart when autoRestart is enabled", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const moduleName = "MMM-Test";
|
||||
const { root } = createTempModuleRoot(moduleName);
|
||||
global.root_path = root;
|
||||
|
||||
const updater = await createUpdater({ updateAutorestart: true });
|
||||
const nodeRestartSpy = vi.spyOn(updater, "nodeRestart").mockImplementation(() => {});
|
||||
|
||||
const result = await updater.updateProcess({
|
||||
name: moduleName,
|
||||
updateCommand: `"${process.execPath}" -p 1`
|
||||
});
|
||||
|
||||
expect(result.error).toBe(false);
|
||||
expect(result.updated).toBe(true);
|
||||
expect(result.needRestart).toBe(false);
|
||||
expect(nodeRestartSpy).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(nodeRestartSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("nodeRestart", () => {
|
||||
it("exits without spawning when running under PM2", async () => {
|
||||
process.env.pm_id = "0";
|
||||
|
||||
const updater = await createUpdater();
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {});
|
||||
const spawnSpy = vi.spyOn(updater, "_spawnDetachedSelf").mockImplementation(() => {});
|
||||
|
||||
updater.nodeRestart();
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
expect(spawnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("spawns a detached child process when not running under PM2", async () => {
|
||||
delete process.env.pm_id;
|
||||
|
||||
const updater = await createUpdater();
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {});
|
||||
const spawnSpy = vi.spyOn(updater, "_spawnDetachedSelf").mockImplementation(() => {});
|
||||
|
||||
updater.nodeRestart();
|
||||
|
||||
expect(spawnSpy).toHaveBeenCalledOnce();
|
||||
expect(exitSpy).toHaveBeenCalledOnce();
|
||||
expect(exitSpy).not.toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
import { vi, describe, beforeEach, afterEach, it, expect } from "vitest";
|
||||
|
||||
/**
|
||||
* Creates a fresh GitHelper instance with isolated mocks for each test run.
|
||||
* @param {{ current: import("vitest").Mock | null }} fsStatSyncMockRef reference to the mocked fs.statSync.
|
||||
* @param {{ current: { error: import("vitest").Mock; info: import("vitest").Mock } | null }} loggerMockRef reference to logger stubs.
|
||||
* @param {{ current: import("vitest").MockInstance | null }} execGitSpyRef reference to the execGit spy.
|
||||
* @returns {Promise<unknown>} resolved GitHelper instance.
|
||||
*/
|
||||
async function createGitHelper (fsStatSyncMockRef, loggerMockRef, execGitSpyRef) {
|
||||
vi.resetModules();
|
||||
|
||||
fsStatSyncMockRef.current = vi.fn();
|
||||
loggerMockRef.current = { error: vi.fn(), info: vi.fn() };
|
||||
|
||||
vi.doMock("node:fs", () => ({
|
||||
statSync: fsStatSyncMockRef.current
|
||||
}));
|
||||
|
||||
vi.doMock("logger", () => loggerMockRef.current);
|
||||
|
||||
const defaults = await import("../../../js/defaults");
|
||||
const gitHelperModule = await import(`../../../${defaults.defaultModulesDir}/updatenotification/git_helper.js`);
|
||||
const GitHelper = gitHelperModule.default || gitHelperModule;
|
||||
const instance = new GitHelper();
|
||||
execGitSpyRef.current = vi.spyOn(instance, "execGit");
|
||||
instance.__loggerMock = loggerMockRef.current;
|
||||
return instance;
|
||||
}
|
||||
|
||||
describe("Updatenotification", () => {
|
||||
const fsStatSyncMockRef = { current: null };
|
||||
const loggerMockRef = { current: null };
|
||||
const execGitSpyRef = { current: null };
|
||||
let gitHelper;
|
||||
|
||||
let gitRemoteOut;
|
||||
let gitRevParseOut;
|
||||
let gitStatusOut;
|
||||
let gitFetchOut;
|
||||
let gitRevListCountOut;
|
||||
let gitRevListOut;
|
||||
let gitFetchErr;
|
||||
let gitTagListOut;
|
||||
|
||||
const getExecutedCommands = () => execGitSpyRef.current.mock.calls.map((call) => call.slice(1).join(" "));
|
||||
|
||||
beforeEach(async () => {
|
||||
gitHelper = await createGitHelper(fsStatSyncMockRef, loggerMockRef, execGitSpyRef);
|
||||
|
||||
fsStatSyncMockRef.current.mockReturnValue({ isDirectory: () => true });
|
||||
|
||||
gitRemoteOut = "";
|
||||
gitRevParseOut = "";
|
||||
gitStatusOut = "";
|
||||
gitFetchOut = "";
|
||||
gitRevListCountOut = "";
|
||||
gitRevListOut = "";
|
||||
gitFetchErr = "";
|
||||
gitTagListOut = "";
|
||||
|
||||
execGitSpyRef.current.mockImplementation((_folder, ...args) => {
|
||||
const command = args.join(" ");
|
||||
|
||||
if (command === "remote -v") {
|
||||
return Promise.resolve({ stdout: gitRemoteOut, stderr: "" });
|
||||
}
|
||||
|
||||
if (command === "rev-parse HEAD") {
|
||||
return Promise.resolve({ stdout: gitRevParseOut, stderr: "" });
|
||||
}
|
||||
|
||||
if (command === "status -sb") {
|
||||
return Promise.resolve({ stdout: gitStatusOut, stderr: "" });
|
||||
}
|
||||
|
||||
if (command === "fetch -n --dry-run") {
|
||||
return Promise.resolve({ stdout: gitFetchOut, stderr: gitFetchErr });
|
||||
}
|
||||
|
||||
if (command.startsWith("rev-list --ancestry-path --count ")) {
|
||||
return Promise.resolve({ stdout: gitRevListCountOut, stderr: "" });
|
||||
}
|
||||
|
||||
if (command.startsWith("rev-list --ancestry-path ")) {
|
||||
return Promise.resolve({ stdout: gitRevListOut, stderr: "" });
|
||||
}
|
||||
|
||||
if (command === "ls-remote -q --tags --refs") {
|
||||
return Promise.resolve({ stdout: gitTagListOut, stderr: "" });
|
||||
}
|
||||
|
||||
return Promise.resolve({ stdout: "", stderr: "" });
|
||||
});
|
||||
|
||||
if (gitHelper.execGit !== execGitSpyRef.current) {
|
||||
throw new Error("execGit spy not applied");
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
gitHelper.gitRepos = [];
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("getRefDiffFromFetchDryRun", () => {
|
||||
it("extracts only commit range from matching fetch line", () => {
|
||||
const fetchOutput = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 develop -> origin/develop\n";
|
||||
|
||||
expect(gitHelper.getRefDiffFromFetchDryRun(fetchOutput, "develop")).toBe("60e0377..332e429");
|
||||
});
|
||||
|
||||
it("matches branch name exactly", () => {
|
||||
const fetchOutput = "From github.com:MagicMirrorOrg/MagicMirror\n1111111..2222222 main-feature -> origin/main-feature\n3333333..4444444 main -> origin/main\n";
|
||||
|
||||
expect(gitHelper.getRefDiffFromFetchDryRun(fetchOutput, "main")).toBe("3333333..4444444");
|
||||
});
|
||||
|
||||
it("returns fallback range when matching line is missing", () => {
|
||||
const fetchOutput = "From github.com:MagicMirrorOrg/MagicMirror\n";
|
||||
|
||||
expect(gitHelper.getRefDiffFromFetchDryRun(fetchOutput, "develop")).toBe("develop..origin/develop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MagicMirror on develop", () => {
|
||||
const moduleName = "MagicMirror";
|
||||
|
||||
beforeEach(() => {
|
||||
gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n";
|
||||
gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada";
|
||||
gitStatusOut = "## develop...origin/develop\n M tests/unit/functions/updatenotification_spec.js\n";
|
||||
gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 develop -> origin/develop\n";
|
||||
gitRevListCountOut = "5";
|
||||
|
||||
gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }];
|
||||
});
|
||||
|
||||
it("returns status information", async () => {
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 60e0377..332e429",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("returns status information early if isBehindInStatus", async () => {
|
||||
gitStatusOut = "## develop...origin/develop [behind 5]";
|
||||
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("excludes repo if status can't be retrieved", async () => {
|
||||
const errorMessage = "Failed to retrieve status";
|
||||
execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
|
||||
|
||||
expect(gitHelper.gitRepos).toHaveLength(1);
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos).toHaveLength(0);
|
||||
expect(execGitSpyRef.current.mock.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MagicMirror on master (empty taglist)", () => {
|
||||
const moduleName = "MagicMirror";
|
||||
|
||||
beforeEach(() => {
|
||||
gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n";
|
||||
gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada";
|
||||
gitStatusOut = "## master...origin/master\n M tests/unit/functions/updatenotification_spec.js\n";
|
||||
gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 master -> origin/master\n";
|
||||
gitRevListCountOut = "5";
|
||||
|
||||
gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }];
|
||||
});
|
||||
|
||||
it("returns status information", async () => {
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 60e0377..332e429",
|
||||
"ls-remote -q --tags --refs",
|
||||
"rev-list --ancestry-path 60e0377..332e429",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("returns status information early if isBehindInStatus", async () => {
|
||||
gitStatusOut = "## master...origin/master [behind 5]";
|
||||
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 60e0377..332e429",
|
||||
"ls-remote -q --tags --refs",
|
||||
"rev-list --ancestry-path 60e0377..332e429",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("excludes repo if status can't be retrieved", async () => {
|
||||
const errorMessage = "Failed to retrieve status";
|
||||
execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
|
||||
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MagicMirror on master with match in taglist", () => {
|
||||
const moduleName = "MagicMirror";
|
||||
|
||||
beforeEach(() => {
|
||||
gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n";
|
||||
gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada";
|
||||
gitStatusOut = "## master...origin/master\n M tests/unit/functions/updatenotification_spec.js\n";
|
||||
gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 master -> origin/master\n";
|
||||
gitRevListCountOut = "5";
|
||||
gitTagListOut = "332e429a41f1a2339afd4f0ae96dd125da6beada\ttag\n";
|
||||
gitRevListOut = "332e429a41f1a2339afd4f0ae96dd125da6beada\n";
|
||||
|
||||
gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }];
|
||||
});
|
||||
|
||||
it("returns status information", async () => {
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 60e0377..332e429",
|
||||
"ls-remote -q --tags --refs",
|
||||
"rev-list --ancestry-path 60e0377..332e429",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("returns status information early if isBehindInStatus", async () => {
|
||||
gitStatusOut = "## master...origin/master [behind 5]";
|
||||
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 60e0377..332e429",
|
||||
"ls-remote -q --tags --refs",
|
||||
"rev-list --ancestry-path 60e0377..332e429",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("excludes repo if status can't be retrieved", async () => {
|
||||
const errorMessage = "Failed to retrieve status";
|
||||
execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
|
||||
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MagicMirror on master without match in taglist", () => {
|
||||
const moduleName = "MagicMirror";
|
||||
|
||||
beforeEach(() => {
|
||||
gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n";
|
||||
gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada";
|
||||
gitStatusOut = "## master...origin/master\n M tests/unit/functions/updatenotification_spec.js\n";
|
||||
gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 master -> origin/master\n";
|
||||
gitRevListCountOut = "5";
|
||||
gitTagListOut = "xxxe429a41f1a2339afd4f0ae96dd125da6beada\ttag\n";
|
||||
gitRevListOut = "332e429a41f1a2339afd4f0ae96dd125da6beada\n";
|
||||
|
||||
gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }];
|
||||
});
|
||||
|
||||
it("returns status information", async () => {
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 60e0377..332e429",
|
||||
"ls-remote -q --tags --refs",
|
||||
"rev-list --ancestry-path 60e0377..332e429",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("returns status information early if isBehindInStatus", async () => {
|
||||
gitStatusOut = "## master...origin/master [behind 5]";
|
||||
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 60e0377..332e429",
|
||||
"ls-remote -q --tags --refs",
|
||||
"rev-list --ancestry-path 60e0377..332e429",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("excludes repo if status can't be retrieved", async () => {
|
||||
const errorMessage = "Failed to retrieve status";
|
||||
execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
|
||||
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom module", () => {
|
||||
const moduleName = "MMM-Fuel";
|
||||
|
||||
beforeEach(() => {
|
||||
gitRemoteOut = `origin\thttps://github.com/fewieden/${moduleName}.git (fetch)\norigin\thttps://github.com/fewieden/${moduleName}.git (push)\n`;
|
||||
gitRevParseOut = "9d8310163da94441073a93cead711ba43e8888d0";
|
||||
gitStatusOut = "## master...origin/master";
|
||||
gitFetchErr = `From https://github.com/fewieden/${moduleName}\n19f7faf..9d83101 master -> origin/master`;
|
||||
gitRevListCountOut = "7";
|
||||
|
||||
gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }];
|
||||
});
|
||||
|
||||
it("returns status information without hash", async () => {
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 19f7faf..9d83101",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user