mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-09-12 12:15:33 +02:00
Added user counter and analytics to the kst4contest page
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
formatGoAccessCheck,
|
||||
generateReports,
|
||||
parseGoAccessVersion
|
||||
} = require("../ops/analytics/generate-reports");
|
||||
|
||||
const GOACCESS_WITHOUT_ZLIB = {
|
||||
version: "1.8.1",
|
||||
geoIpMmdb: true,
|
||||
openSsl: true,
|
||||
zlib: false
|
||||
};
|
||||
|
||||
function goAccessReport(dailyVisits, combined = false) {
|
||||
const report = {
|
||||
general: { total_requests: 1 },
|
||||
visitors: {
|
||||
data: Object.entries(dailyVisits).map(([date, count]) => ({
|
||||
data: date.replaceAll("-", ""),
|
||||
visitors: { count }
|
||||
}))
|
||||
},
|
||||
requests: { data: [] },
|
||||
status_codes: { data: [] },
|
||||
geo_location: { data: [] }
|
||||
};
|
||||
if (combined) {
|
||||
report.virtual_hosts = { data: [] };
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
function fixture(siteDefinitions) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "kst4-analytics-test-"));
|
||||
const stateDirectory = path.join(root, "state");
|
||||
const geoIpCountryDatabase = path.join(root, "GeoLite2-Country.mmdb");
|
||||
const configTemplatePath = path.join(root, "goaccess.conf.template");
|
||||
const registryPath = path.join(root, "sites.json");
|
||||
fs.writeFileSync(geoIpCountryDatabase, "test database placeholder");
|
||||
fs.copyFileSync(
|
||||
path.join(__dirname, "../ops/analytics/goaccess.conf.template"),
|
||||
configTemplatePath
|
||||
);
|
||||
|
||||
const sites = siteDefinitions.map((definition, index) => {
|
||||
const id = definition.id || `site-${index}`;
|
||||
const log = path.join(root, `${id}.log`);
|
||||
fs.writeFileSync(log, "example log line\n");
|
||||
if (definition.rotated !== false) {
|
||||
fs.writeFileSync(`${log}.1`, "rotated example log line\n");
|
||||
}
|
||||
if (definition.compressed) {
|
||||
fs.writeFileSync(`${log}.2.gz`, "compressed placeholder\n");
|
||||
}
|
||||
return {
|
||||
id,
|
||||
hostname: definition.hostname || `${id}.example.test`,
|
||||
analyticsLog: log,
|
||||
activatedOn: definition.activatedOn || "2026-01-01",
|
||||
publicCounter: definition.publicCounter,
|
||||
reportOutputDirectory: path.join(stateDirectory, "reports", id),
|
||||
...(definition.publicCounter
|
||||
? { publicJsonPath: path.join(stateDirectory, "public", `${id}.json`) }
|
||||
: {})
|
||||
};
|
||||
});
|
||||
const registry = {
|
||||
schemaVersion: 1,
|
||||
stateDirectory,
|
||||
counterStatePath: path.join(stateDirectory, "counter-state.json"),
|
||||
lockFile: path.join(root, "run", "generator.lock"),
|
||||
geoIpCountryDatabase,
|
||||
combined: {
|
||||
reportOutputDirectory: path.join(stateDirectory, "reports", "combined")
|
||||
},
|
||||
sites
|
||||
};
|
||||
fs.mkdirSync(stateDirectory, { recursive: true });
|
||||
fs.mkdirSync(registry.combined.reportOutputDirectory, { recursive: true });
|
||||
for (const site of sites) {
|
||||
fs.mkdirSync(site.reportOutputDirectory, { recursive: true });
|
||||
if (site.publicCounter) {
|
||||
fs.mkdirSync(path.dirname(site.publicJsonPath), { recursive: true });
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(registryPath, JSON.stringify(registry));
|
||||
|
||||
return {
|
||||
root,
|
||||
registry,
|
||||
registryPath,
|
||||
configTemplatePath,
|
||||
cleanup: () => fs.rmSync(root, { recursive: true, force: true })
|
||||
};
|
||||
}
|
||||
|
||||
function fakeGoAccess(reports, calls, failureId) {
|
||||
return invocation => {
|
||||
calls.push(invocation);
|
||||
if (invocation.id === failureId) {
|
||||
throw new Error("simulated GoAccess failure");
|
||||
}
|
||||
fs.writeFileSync(
|
||||
invocation.outputJson,
|
||||
JSON.stringify(reports[invocation.id])
|
||||
);
|
||||
fs.writeFileSync(
|
||||
invocation.outputHtml,
|
||||
`<!doctype html><html><body>${"report".repeat(30)}</body></html>`
|
||||
);
|
||||
fs.writeFileSync(path.join(invocation.dbPath, "persisted.db"), invocation.id);
|
||||
};
|
||||
}
|
||||
|
||||
function run(testFixture, reports, calls = [], failureId) {
|
||||
return generateReports({
|
||||
registryPath: testFixture.registryPath,
|
||||
configTemplatePath: testFixture.configTemplatePath,
|
||||
goaccessBinary: "fake-goaccess"
|
||||
}, {
|
||||
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
|
||||
runGoAccess: fakeGoAccess(reports, calls, failureId),
|
||||
now: () => new Date("2026-09-11T07:00:00Z"),
|
||||
skipLock: true
|
||||
});
|
||||
}
|
||||
|
||||
test("replaces repeated daily values and retains older public days", () => {
|
||||
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(testFixture.registry.counterStatePath), { recursive: true });
|
||||
fs.writeFileSync(testFixture.registry.counterStatePath, JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
sites: {
|
||||
alpha: {
|
||||
hostname: "alpha.example.test",
|
||||
since: "2026-01-01",
|
||||
dailyVisits: { "2026-01-01": 7 }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
run(testFixture, {
|
||||
alpha: goAccessReport({ "2026-09-11": 3 }),
|
||||
combined: goAccessReport({ "2026-09-11": 3 }, true)
|
||||
});
|
||||
run(testFixture, {
|
||||
alpha: goAccessReport({ "2026-09-11": 5 }),
|
||||
combined: goAccessReport({ "2026-09-11": 5 }, true)
|
||||
});
|
||||
|
||||
const state = JSON.parse(fs.readFileSync(
|
||||
testFixture.registry.counterStatePath,
|
||||
"utf8"
|
||||
));
|
||||
const published = JSON.parse(fs.readFileSync(
|
||||
testFixture.registry.sites[0].publicJsonPath,
|
||||
"utf8"
|
||||
));
|
||||
assert.deepEqual(state.sites.alpha.dailyVisits, {
|
||||
"2026-01-01": 7,
|
||||
"2026-09-11": 5
|
||||
});
|
||||
assert.deepEqual(published, {
|
||||
schemaVersion: 1,
|
||||
visits: 12,
|
||||
since: "2026-01-01",
|
||||
updatedAt: "2026-09-11T07:00:00Z"
|
||||
});
|
||||
} finally {
|
||||
testFixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps valid outputs unchanged when a GoAccess job fails", () => {
|
||||
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
|
||||
try {
|
||||
const reportDirectory = testFixture.registry.sites[0].reportOutputDirectory;
|
||||
fs.mkdirSync(reportDirectory, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(testFixture.registry.counterStatePath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(testFixture.registry.sites[0].publicJsonPath), { recursive: true });
|
||||
fs.writeFileSync(path.join(reportDirectory, "report.json"), "old-json");
|
||||
fs.writeFileSync(path.join(reportDirectory, "report.html"), "old-html");
|
||||
fs.writeFileSync(testFixture.registry.counterStatePath, "old-state");
|
||||
fs.writeFileSync(testFixture.registry.sites[0].publicJsonPath, "old-public");
|
||||
|
||||
assert.throws(() => run(testFixture, {
|
||||
alpha: goAccessReport({ "2026-09-11": 3 }),
|
||||
combined: goAccessReport({ "2026-09-11": 3 }, true)
|
||||
}, [], "combined"), /simulated GoAccess failure/);
|
||||
|
||||
assert.equal(fs.readFileSync(path.join(reportDirectory, "report.json"), "utf8"), "old-json");
|
||||
assert.equal(fs.readFileSync(path.join(reportDirectory, "report.html"), "utf8"), "old-html");
|
||||
assert.equal(fs.readFileSync(testFixture.registry.counterStatePath, "utf8"), "old-state");
|
||||
assert.equal(fs.readFileSync(testFixture.registry.sites[0].publicJsonPath, "utf8"), "old-public");
|
||||
} finally {
|
||||
testFixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("processes subdomains separately and together without publishing disabled counters", () => {
|
||||
const testFixture = fixture([
|
||||
{ id: "alpha", publicCounter: true, compressed: true },
|
||||
{ id: "bravo", publicCounter: false, rotated: false, compressed: true }
|
||||
]);
|
||||
const calls = [];
|
||||
try {
|
||||
const result = run(testFixture, {
|
||||
alpha: goAccessReport({ "2026-09-11": 3 }),
|
||||
bravo: goAccessReport({ "2026-09-11": 4 }),
|
||||
combined: goAccessReport({ "2026-09-11": 6 }, true)
|
||||
}, calls);
|
||||
|
||||
assert.equal(result.reports, 3);
|
||||
assert.deepEqual(calls.map(call => call.id), ["alpha", "bravo", "combined"]);
|
||||
const alphaLogs = [
|
||||
`${testFixture.registry.sites[0].analyticsLog}.1`,
|
||||
testFixture.registry.sites[0].analyticsLog
|
||||
];
|
||||
const bravoLogs = [testFixture.registry.sites[1].analyticsLog];
|
||||
assert.deepEqual(calls[0].args.slice(0, 2), alphaLogs);
|
||||
assert.deepEqual(calls[1].args.slice(0, 1), bravoLogs);
|
||||
assert.deepEqual(
|
||||
calls[2].args.slice(0, 3),
|
||||
[...alphaLogs, ...bravoLogs]
|
||||
);
|
||||
assert.equal(calls.some(call => call.args.some(argument => argument.endsWith(".gz"))), false);
|
||||
assert.equal(fs.existsSync(path.join(
|
||||
testFixture.registry.stateDirectory,
|
||||
"public",
|
||||
"bravo.json"
|
||||
)), false);
|
||||
assert.equal(fs.existsSync(path.join(
|
||||
testFixture.registry.combined.reportOutputDirectory,
|
||||
"report.html"
|
||||
)), true);
|
||||
} finally {
|
||||
testFixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("dry-run validates generated data without changing production paths", () => {
|
||||
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
|
||||
try {
|
||||
const blockedLockParent = path.join(testFixture.root, "blocked-lock-parent");
|
||||
fs.writeFileSync(blockedLockParent, "not a directory");
|
||||
testFixture.registry.lockFile = path.join(blockedLockParent, "generator.lock");
|
||||
fs.writeFileSync(testFixture.registryPath, JSON.stringify(testFixture.registry));
|
||||
|
||||
const result = generateReports({
|
||||
registryPath: testFixture.registryPath,
|
||||
configTemplatePath: testFixture.configTemplatePath,
|
||||
goaccessBinary: "fake-goaccess",
|
||||
dryRun: true
|
||||
}, {
|
||||
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
|
||||
runGoAccess: fakeGoAccess({
|
||||
alpha: goAccessReport({ "2026-09-11": 3 }),
|
||||
combined: goAccessReport({ "2026-09-11": 3 }, true)
|
||||
}, []),
|
||||
now: () => new Date("2026-09-11T07:00:00Z")
|
||||
});
|
||||
|
||||
assert.equal(result.dryRun, true);
|
||||
assert.equal(fs.statSync(blockedLockParent).isFile(), true);
|
||||
assert.equal(fs.readdirSync(
|
||||
testFixture.registry.sites[0].reportOutputDirectory
|
||||
).length, 0);
|
||||
assert.equal(fs.existsSync(testFixture.registry.sites[0].publicJsonPath), false);
|
||||
assert.equal(fs.existsSync(testFixture.registry.counterStatePath), false);
|
||||
} finally {
|
||||
testFixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("configuration check accepts and explains a GoAccess build without Zlib", () => {
|
||||
const testFixture = fixture([{ id: "alpha", publicCounter: true, rotated: false }]);
|
||||
try {
|
||||
const versionOutput = [
|
||||
"GoAccess - 1.8.1.",
|
||||
"Build configure arguments:",
|
||||
" --enable-geoip=mmdb",
|
||||
" --with-openssl"
|
||||
].join("\n");
|
||||
const capabilities = parseGoAccessVersion(versionOutput);
|
||||
const result = generateReports({
|
||||
registryPath: testFixture.registryPath,
|
||||
configTemplatePath: testFixture.configTemplatePath,
|
||||
goaccessBinary: "fake-goaccess",
|
||||
check: true
|
||||
}, {
|
||||
checkGoAccess: () => capabilities,
|
||||
runGoAccess: () => {
|
||||
throw new Error("configuration check must not generate reports");
|
||||
}
|
||||
});
|
||||
const message = formatGoAccessCheck(result.goAccess);
|
||||
|
||||
assert.deepEqual(capabilities, GOACCESS_WITHOUT_ZLIB);
|
||||
assert.equal(result.checked, true);
|
||||
assert.match(message, /Zlib not detected/);
|
||||
assert.match(message, /no Zlib support/);
|
||||
assert.match(message, /valid for regular mode/);
|
||||
assert.match(message, /optional uncompressed \.1 rotation/);
|
||||
assert.match(message, /older \.gz logs are not imported/);
|
||||
} finally {
|
||||
testFixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("detects Zlib support when it is present", () => {
|
||||
const capabilities = parseGoAccessVersion([
|
||||
"GoAccess - 1.8.1.",
|
||||
"Build configure arguments:",
|
||||
" --enable-geoip=mmdb --with-openssl --with-zlib"
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(capabilities.zlib, true);
|
||||
assert.match(formatGoAccessCheck(capabilities), /Zlib enabled/);
|
||||
assert.doesNotMatch(formatGoAccessCheck(capabilities), /older \.gz logs/);
|
||||
});
|
||||
|
||||
test("configuration check rejects GoAccess without GeoIP2 MMDB support", () => {
|
||||
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
|
||||
try {
|
||||
assert.throws(() => generateReports({
|
||||
registryPath: testFixture.registryPath,
|
||||
configTemplatePath: testFixture.configTemplatePath,
|
||||
check: true
|
||||
}, {
|
||||
checkGoAccess: () => ({
|
||||
version: "1.8.1",
|
||||
geoIpMmdb: false,
|
||||
openSsl: true,
|
||||
zlib: false
|
||||
})
|
||||
}), error => error.exitCode === 2
|
||||
&& /must be built with GeoIP2\/MMDB support/.test(error.message));
|
||||
} finally {
|
||||
testFixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("configuration check verifies analytics-log readability", () => {
|
||||
const testFixture = fixture([{ id: "alpha", publicCounter: true, rotated: false }]);
|
||||
const originalAccessSync = fs.accessSync;
|
||||
try {
|
||||
fs.accessSync = (filePath, mode) => {
|
||||
if (filePath === testFixture.registry.sites[0].analyticsLog
|
||||
&& mode === fs.constants.R_OK) {
|
||||
const error = new Error("permission denied");
|
||||
error.code = "EACCES";
|
||||
throw error;
|
||||
}
|
||||
return originalAccessSync(filePath, mode);
|
||||
};
|
||||
|
||||
assert.throws(() => generateReports({
|
||||
registryPath: testFixture.registryPath,
|
||||
configTemplatePath: testFixture.configTemplatePath,
|
||||
check: true
|
||||
}, {
|
||||
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
|
||||
}), /analytics log is not readable by the current user/);
|
||||
} finally {
|
||||
fs.accessSync = originalAccessSync;
|
||||
testFixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("configuration check reports missing output directories", () => {
|
||||
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
|
||||
try {
|
||||
fs.rmSync(testFixture.registry.sites[0].reportOutputDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
});
|
||||
|
||||
assert.throws(() => generateReports({
|
||||
registryPath: testFixture.registryPath,
|
||||
configTemplatePath: testFixture.configTemplatePath,
|
||||
check: true
|
||||
}, {
|
||||
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
|
||||
}), /report output directory for alpha does not exist/);
|
||||
} finally {
|
||||
testFixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a compressed log as the regular analytics input", () => {
|
||||
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
|
||||
try {
|
||||
testFixture.registry.sites[0].analyticsLog += ".2.gz";
|
||||
fs.writeFileSync(testFixture.registryPath, JSON.stringify(testFixture.registry));
|
||||
|
||||
assert.throws(() => generateReports({
|
||||
registryPath: testFixture.registryPath,
|
||||
configTemplatePath: testFixture.configTemplatePath,
|
||||
check: true
|
||||
}, {
|
||||
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
|
||||
}), /must identify the current uncompressed log/);
|
||||
} finally {
|
||||
testFixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Nginx filters exclude non-page traffic before analytics logging", () => {
|
||||
const source = fs.readFileSync(path.join(
|
||||
__dirname,
|
||||
"../ops/analytics/nginx/analytics-filters.conf.example"
|
||||
), "utf8");
|
||||
|
||||
for (const excluded of [
|
||||
"/visitor-count.json",
|
||||
"/kst4ContestVersionInfo.xml",
|
||||
"/sitemap.xml",
|
||||
"/robots.txt",
|
||||
"manual/assets",
|
||||
"healthz"
|
||||
]) {
|
||||
assert.match(source, new RegExp(excluded.replaceAll("/", "\\/"), "i"));
|
||||
}
|
||||
assert.doesNotMatch(source, /^\s*=\//m);
|
||||
assert.match(source, /^\s*\/visitor-count\.json\s+0;/m);
|
||||
assert.match(source, /^\s*\/health\s+0;/m);
|
||||
assert.match(source, /\|map\|/);
|
||||
assert.match(source, /\$uri/);
|
||||
assert.match(source, /known_bot/);
|
||||
});
|
||||
|
||||
test("server templates use the production GeoIP path and permission model", () => {
|
||||
const registry = JSON.parse(fs.readFileSync(path.join(
|
||||
__dirname,
|
||||
"../ops/analytics/sites.example.json"
|
||||
), "utf8"));
|
||||
const service = fs.readFileSync(path.join(
|
||||
__dirname,
|
||||
"../ops/analytics/systemd/hamradioonline-analytics.service.example"
|
||||
), "utf8");
|
||||
const generator = fs.readFileSync(path.join(
|
||||
__dirname,
|
||||
"../ops/analytics/generate-reports.js"
|
||||
), "utf8");
|
||||
|
||||
assert.equal(registry.geoIpCountryDatabase, "/var/lib/GeoIP/GeoLite2-Country.mmdb");
|
||||
assert.match(service, /\/var\/lib\/GeoIP\/GeoLite2-Country\.mmdb/);
|
||||
assert.match(service, /^StateDirectoryMode=0711$/m);
|
||||
assert.match(generator, /fs\.fchmodSync\(handle, mode\)/);
|
||||
assert.match(generator, /publicFile\.destination, publicFile\.content, 0o644/);
|
||||
});
|
||||
|
||||
test("public counter template disables logging and sets cache and type headers", () => {
|
||||
const source = fs.readFileSync(path.join(
|
||||
__dirname,
|
||||
"../ops/analytics/nginx/public-counter.conf.example"
|
||||
), "utf8");
|
||||
|
||||
assert.match(source, /^\s*access_log off;$/m);
|
||||
assert.match(source, /Cache-Control "public, max-age=3600" always/);
|
||||
assert.match(source, /X-Content-Type-Options "nosniff" always/);
|
||||
});
|
||||
|
||||
test("statistics vhost preserves ACME renewal and protects HTTPS reports", () => {
|
||||
const source = fs.readFileSync(path.join(
|
||||
__dirname,
|
||||
"../ops/analytics/nginx/stats-vhost.conf.example"
|
||||
), "utf8");
|
||||
const bootstrap = fs.readFileSync(path.join(
|
||||
__dirname,
|
||||
"../ops/analytics/nginx/stats-vhost-http-bootstrap.conf.example"
|
||||
), "utf8");
|
||||
const httpsOffset = source.search(/server\s*\{\s*listen 443 ssl http2;/);
|
||||
assert.notEqual(httpsOffset, -1);
|
||||
const httpSource = source.slice(0, httpsOffset);
|
||||
const httpsSource = source.slice(httpsOffset);
|
||||
|
||||
assert.match(httpSource, /^\s*listen 80;$/m);
|
||||
assert.match(httpSource, /^\s*access_log off;$/m);
|
||||
assert.match(httpSource, /location \^~ \/\.well-known\/acme-challenge\//);
|
||||
assert.match(httpSource, /root \/var\/lib\/letsencrypt;/);
|
||||
assert.match(httpSource, /return 301 https:\/\/\$host\$request_uri;/);
|
||||
assert.match(httpsSource, /^\s*listen 443 ssl http2;$/m);
|
||||
assert.match(httpsSource, /^\s*auth_basic "Private project statistics";$/m);
|
||||
assert.match(httpsSource, /^\s*access_log off;$/m);
|
||||
assert.match(httpsSource, /location = \/[\s\S]*return 302 \/combined\/;/);
|
||||
assert.match(httpsSource, /include \/etc\/letsencrypt\/options-ssl-nginx\.conf;/);
|
||||
assert.match(httpsSource, /ssl_dhparam \/etc\/letsencrypt\/ssl-dhparams\.pem;/);
|
||||
assert.doesNotMatch(source, /listen \[::\]/);
|
||||
assert.match(bootstrap, /^\s*listen 80;$/m);
|
||||
assert.doesNotMatch(bootstrap, /ssl_certificate/);
|
||||
});
|
||||
|
||||
test("logrotate uses the Ubuntu Nginx rotation action", () => {
|
||||
const source = fs.readFileSync(path.join(
|
||||
__dirname,
|
||||
"../ops/analytics/logrotate/hamradioonline-analytics.example"
|
||||
), "utf8");
|
||||
|
||||
assert.match(source, /invoke-rc\.d nginx rotate >\/dev\/null 2>&1/);
|
||||
assert.match(source, /^\s*delaycompress$/m);
|
||||
assert.match(source, /^\s*rotate 14$/m);
|
||||
assert.match(source, /^\s*create 0640 www-data hamradio-analytics$/m);
|
||||
});
|
||||
|
||||
test("website package records the Node.js 18.19.1 baseline", () => {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "../package.json")));
|
||||
const packageLock = JSON.parse(fs.readFileSync(path.join(__dirname, "../package-lock.json")));
|
||||
|
||||
assert.equal(packageJson.engines.node, ">=18.19.1");
|
||||
assert.equal(packageLock.packages[""].engines.node, ">=18.19.1");
|
||||
});
|
||||
Reference in New Issue
Block a user