chore: import upstream snapshot with attribution
Enforce Pull-Request Rules / check (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:19:24 +08:00
commit dbe3ade0dc
449 changed files with 71828 additions and 0 deletions
@@ -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",
}
`;
+71
View File
@@ -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"));
});
});
+579
View File
@@ -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);
});
});
});
+119
View File
@@ -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",
]
`);
});
});
});