diff --git a/api/src/proxy.js b/api/src/proxy.js index d75cf239..3e4c7c87 100644 --- a/api/src/proxy.js +++ b/api/src/proxy.js @@ -357,12 +357,17 @@ function fetchJSON(raw, opts = {}) { contentType: (res.headers['content-type'] || '').toLowerCase(), }); const body = Buffer.concat(bufs).toString('utf8'); - if (opts.raw) return done(resolve, { status: res.statusCode, data: body }); + /* Keep headers opt-in. Some widgets return the reply as is, and headers can carry cookies. */ + const reply = data => + opts.withHeaders + ? { status: res.statusCode, data, headers: res.headers } + : { status: res.statusCode, data }; + if (opts.raw) return done(resolve, reply(body)); const ct = (res.headers['content-type'] || '').toLowerCase(); try { - done(resolve, { status: res.statusCode, data: JSON.parse(body) }); + done(resolve, reply(JSON.parse(body))); } catch { - if (looksLikeMetrics(ct, body)) done(resolve, { status: res.statusCode, data: parsePrometheus(body) }); + if (looksLikeMetrics(ct, body)) done(resolve, reply(parsePrometheus(body))); else if (ct.includes('xml') || body.trimStart().startsWith('<')) { const parsed = parseXml(body); if (parsed['#truncated']) { @@ -373,8 +378,8 @@ function fetchJSON(raw, opts = {}) { bytes: body.length, }); } - done(resolve, { status: res.statusCode, data: parsed }); - } else done(resolve, { status: res.statusCode, data: body }); + done(resolve, reply(parsed)); + } else done(resolve, reply(body)); } }); }, diff --git a/api/src/secret-scope.js b/api/src/secret-scope.js index 804e890a..cb346787 100644 --- a/api/src/secret-scope.js +++ b/api/src/secret-scope.js @@ -5,6 +5,7 @@ const { SAVED_ROW, secretSpec, cosmeticSpec, transientSpec } = require('./widget-secrets'); const { toRows, loginOf } = require('./badge-headers'); +const { withManifestDefaults } = require('./widget-defaults'); function stableEqual(a, b) { if (a === b) return true; @@ -89,7 +90,8 @@ function alignSavedConfig(newConfig, savedConfig, entry) { function widgetConfigMatchesSaved(newConfig, savedConfig, entry) { if (!entry) return false; - return stableEqual(stripWidgetSecrets(newConfig, entry), stripWidgetSecrets(savedConfig, entry)); + const stripped = c => stripWidgetSecrets(withManifestDefaults(c, entry), entry); + return stableEqual(stripped(newConfig), stripped(savedConfig)); } const blankStored = (obj, old, k) => !!old && old[k] != null && old[k] !== '' && !(obj && obj[k]); diff --git a/api/src/widget-data.js b/api/src/widget-data.js index 595b4b62..e3321c3b 100644 --- a/api/src/widget-data.js +++ b/api/src/widget-data.js @@ -10,6 +10,7 @@ const { preserveWidgetSecrets } = require('./widget-secrets'); const { alignSavedConfig, leavesStoredSecretBlank, RETYPE_MESSAGE } = require('./secret-scope'); const { dispatchProvider } = require('./provider-dispatch'); const { widgetSettings } = require('./widget-settings'); +const { withManifestDefaults } = require('./widget-defaults'); const { IS_DEMO } = require('./demo'); const demoData = require('./demo-data'); const log = require('./log'); @@ -128,7 +129,7 @@ function widgetParams(searchParams, manifest) { const KEPT_PER_WIDGET = 16; async function getWidgetData(item, entry, endpointName, searchParams, fetch, row = null, isOptions = false) { - const wc = item.widgetConfig || {}; + const wc = withManifestDefaults(item.widgetConfig, entry); /* An options fetch must run the real path, or the editor shows fabricated options. */ if (IS_DEMO && !isOptions && entry.hasDemoFn) { diff --git a/api/src/widget-defaults.js b/api/src/widget-defaults.js new file mode 100644 index 00000000..80fa2bb5 --- /dev/null +++ b/api/src/widget-defaults.js @@ -0,0 +1,58 @@ +/* The secret check and the data function must both read a config through this. + If only one does, a stored secret can be restored for one destination and + sent to another. */ + +function defaultsOf(fields) { + const seen = new Map(); + for (const f of Array.isArray(fields) ? fields : []) { + if (!f || typeof f.key !== 'string' || !f.key) continue; + const usable = f.default !== undefined && f.cosmetic !== true && f.transient !== true && f.type !== 'secret'; + const value = usable ? JSON.stringify(f.default) : null; + const prev = seen.get(f.key); + seen.set(f.key, prev === undefined || prev === value ? value : null); + } + const out = new Map(); + for (const [k, v] of seen) if (v !== null) out.set(k, v); + return out; +} + +function fill(obj, defaults) { + let out = obj; + for (const [k, v] of defaults) { + if (Object.hasOwn(obj, k) && obj[k] !== undefined) continue; + if (out === obj) out = { ...obj }; + out[k] = JSON.parse(v); + } + return out; +} + +const isObj = v => !!v && typeof v === 'object' && !Array.isArray(v); + +function withManifestDefaults(config, entry) { + const fields = (entry && entry.manifest && entry.manifest.fields) || []; + let out = fill(isObj(config) ? config : {}, defaultsOf(fields)); + const nested = new Map(); + for (const f of fields) { + if (!f || typeof f.key !== 'string' || !Array.isArray(f.fields)) continue; + if (f.type !== 'group' && f.type !== 'object') continue; + const prev = nested.get(f.key); + nested.set(f.key, { type: f.type, fields: (prev ? prev.fields : []).concat(f.fields) }); + } + for (const [k, { type, fields: sub }] of nested) { + const defaults = defaultsOf(sub); + if (!defaults.size) continue; + const v = out[k]; + let next = v; + if (type === 'group' && Array.isArray(v)) { + const rows = v.map(row => (isObj(row) ? fill(row, defaults) : row)); + if (rows.some((row, i) => row !== v[i])) next = rows; + } else if (type === 'object' && isObj(v)) next = fill(v, defaults); + if (next !== v) { + if (out === config) out = { ...out }; + out[k] = next; + } + } + return out; +} + +module.exports = { withManifestDefaults }; diff --git a/api/test/github-data.test.js b/api/test/github-data.test.js index ec39b3af..13f696b4 100644 --- a/api/test/github-data.test.js +++ b/api/test/github-data.test.js @@ -49,3 +49,173 @@ test('an empty search is a valid empty list', async () => { const r = await dataFn(ctxFor(PRS, { status: 200, data: { total_count: 0, items: [] } })); assert.deepEqual(r.items, []); }); + +const { normalizeBase } = require('../src/widget-data'); +const { serviceLink } = require('../../ui/js/service-link.js'); + +function recordingCtx(config, reply) { + const calls = []; + const ctx = { + config, + normalizeBase, + serviceLink, + fetchJSON: async (url, opts) => { + calls.push({ url: new URL(url), opts }); + return typeof reply === 'function' ? reply(url) : reply; + }, + ...errorParts(), + }; + return { ctx, calls }; +} + +const GITEA = { githubService: 'gitea', githubUrl: 'https://git.lan/', githubUser: 'me', githubToken: 't' }; + +for (const service of ['gitea', 'forgejo']) { + test(`${service} pull requests search the token owner's open pull requests`, async () => { + const { ctx, calls } = recordingCtx( + { ...GITEA, githubService: service, githubPrFilters: ['created', 'review-requested'] }, + { + status: 200, + headers: { 'x-total-count': '31' }, + data: [ + { number: 4, title: 'Fix', repository: { full_name: 'me/app' }, html_url: 'https://git.lan/me/app/pulls/4' }, + ], + }, + ); + const r = await dataFn(ctx); + const { url, opts } = calls[0]; + assert.equal(url.origin + url.pathname, 'https://git.lan/api/v1/repos/issues/search'); + assert.equal(url.searchParams.get('type'), 'pulls'); + assert.equal(url.searchParams.get('state'), 'open'); + assert.equal(url.searchParams.get('created'), 'true'); + assert.equal(url.searchParams.get('review_requested'), 'true'); + assert.equal(opts.headers.Authorization, 'token t'); + assert.equal(r.totalCount, 31); + assert.deepEqual(r.items, [{ number: 4, title: 'Fix', repo: 'me/app', url: 'https://git.lan/me/app/pulls/4' }]); + assert.equal(r.allUrl, 'https://git.lan/pulls'); + }); +} + +test('gitea counts the listed pull requests when the total header is missing', async () => { + const { ctx } = recordingCtx(GITEA, { + status: 200, + data: [ + { number: 1, title: 'a' }, + { number: 2, title: 'b' }, + ], + }); + assert.equal((await dataFn(ctx)).totalCount, 2); +}); + +test('gitea without a server URL fails as a setup problem', async () => { + const { ctx } = recordingCtx({ ...GITEA, githubUrl: '' }, { status: 200, data: [] }); + await assert.rejects(dataFn(ctx), e => e.kind === ctx.KIND.INVALID && /URL/.test(e.message)); +}); + +for (const [status, kind] of [ + [401, 'AUTH'], + [403, 'AUTH'], + [404, 'INVALID'], +]) { + test(`gitea answering ${status} fails as ${kind}`, async () => { + const { ctx } = recordingCtx(GITEA, { status, data: { message: 'x' } }); + await assert.rejects(dataFn(ctx), e => e.kind === ctx.KIND[kind]); + }); +} + +test('gitea contributions pass the recent heatmap slots on as pairs', async () => { + const now = Math.floor(Date.now() / 1000); + const { ctx, calls } = recordingCtx( + { ...GITEA, githubView: 'contributions' }, + { + status: 200, + data: [ + { timestamp: now - 3600, contributions: 2 }, + { timestamp: now - 3 * 86400, contributions: '4' }, + { timestamp: now - 400 * 86400, contributions: 9 }, + { timestamp: now, contributions: 0 }, + { timestamp: 'x', contributions: 1 }, + null, + ], + }, + ); + const r = await dataFn(ctx); + assert.equal(calls[0].url.pathname, '/api/v1/users/me/heatmap'); + assert.equal(r.view, 'contributions'); + assert.deepEqual(r.slots, [ + [now - 3600, 2], + [now - 3 * 86400, 4], + ]); + assert.equal(r.profileUrl, 'https://git.lan/me'); +}); + +test('a server address without a scheme is read and linked over https', async () => { + const { ctx, calls } = recordingCtx({ ...GITEA, githubUrl: 'git.lan' }, { status: 200, data: [] }); + const r = await dataFn(ctx); + assert.equal(calls[0].url.origin, 'https://git.lan'); + assert.equal(r.allUrl, 'https://git.lan/pulls'); + const plain = recordingCtx({ ...GITEA, githubUrl: 'http://git.lan' }, { status: 200, data: [] }); + await dataFn(plain.ctx); + assert.equal(plain.calls[0].url.origin, 'http://git.lan'); +}); + +const GITLAB = { githubService: 'gitlab', githubUrl: 'https://gitlab.com', githubUser: 'me', githubToken: 't' }; + +test('gitlab lists open merge requests by the chosen filters', async () => { + const { ctx, calls } = recordingCtx( + { ...GITLAB, githubPrFilters: ['assigned', 'mentioned'] }, + { + status: 200, + headers: { 'x-total': '12' }, + data: [ + { + iid: 9, + title: 'Bump', + references: { full: 'me/app!9' }, + web_url: 'https://gitlab.com/me/app/-/merge_requests/9', + }, + ], + }, + ); + const r = await dataFn(ctx); + const { url, opts } = calls[0]; + assert.equal(url.pathname, '/api/v4/merge_requests'); + assert.equal(url.searchParams.get('state'), 'opened'); + assert.equal(url.searchParams.get('assignee_username'), 'me'); + assert.equal(url.searchParams.has('author_username'), false); + assert.equal(opts.headers['PRIVATE-TOKEN'], 't'); + assert.deepEqual(r.filters, ['assigned']); + assert.equal(r.totalCount, 12); + assert.deepEqual(r.items, [ + { number: 9, title: 'Bump', repo: 'me/app', url: 'https://gitlab.com/me/app/-/merge_requests/9' }, + ]); + assert.equal(r.allUrl, 'https://gitlab.com/dashboard/merge_requests'); +}); + +test('gitlab with a saved contributions view fails as a setup problem', async () => { + const { ctx, calls } = recordingCtx({ ...GITLAB, githubView: 'contributions' }, { status: 200, data: [] }); + await assert.rejects(dataFn(ctx), e => e.kind === ctx.KIND.INVALID && /contribution/.test(e.message)); + assert.equal(calls.length, 0); +}); + +test('gitlab with only unsupported filters searches by author', async () => { + const { ctx, calls } = recordingCtx({ ...GITLAB, githubPrFilters: ['mentioned'] }, { status: 200, data: [] }); + assert.deepEqual((await dataFn(ctx)).filters, ['created']); + assert.equal(calls[0].url.searchParams.get('author_username'), 'me'); +}); + +test('an unknown service reads GitHub', async () => { + const { ctx, calls } = recordingCtx({ ...PRS, githubService: 'svn' }, { status: 200, data: { items: [] } }); + await dataFn(ctx); + assert.equal(calls[0].url.host, 'api.github.com'); +}); + +test('github contributions link the profile unless a link URL is set', async () => { + const reply = { + status: 200, + data: { data: { user: { contributionsCollection: { contributionCalendar: { weeks: [] } } } } }, + }; + assert.equal((await dataFn(recordingCtx(CONTRIBUTIONS, reply).ctx)).profileUrl, 'https://github.com/octo'); + const own = { ...CONTRIBUTIONS, githubHref: 'https://me.example' }; + assert.equal((await dataFn(recordingCtx(own, reply).ctx)).profileUrl, 'https://me.example'); +}); diff --git a/api/test/proxy.test.js b/api/test/proxy.test.js index 3244fdd3..aa0856c1 100644 --- a/api/test/proxy.test.js +++ b/api/test/proxy.test.js @@ -202,6 +202,23 @@ test('getHostIp returns an empty string when no config file exists', () => { assert.equal(getHostIp(), ''); }); +test('fetchJSON returns response headers only when asked', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json', 'X-Total-Count': '7', 'Set-Cookie': 'sid=1' }); + res.end('[]'); + }); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + const port = server.address().port; + try { + const plain = await fetchJSON(`http://127.0.0.1:${port}/`, { timeout: 3000 }); + assert.deepEqual(Object.keys(plain).sort(), ['data', 'status']); + const withHeaders = await fetchJSON(`http://127.0.0.1:${port}/`, { withHeaders: true, timeout: 3000 }); + assert.equal(withHeaders.headers['x-total-count'], '7'); + } finally { + server.close(); + } +}); + test('fetchJSON returns the untouched body with opts.raw, and auto-parses without it', async () => { const metrics = '# TYPE conduit_connected_clients gauge\nconduit_connected_clients 5\n'; const server = http.createServer((req, res) => { diff --git a/api/test/secret-scope.test.js b/api/test/secret-scope.test.js index 5fa73847..35e5f33a 100644 --- a/api/test/secret-scope.test.js +++ b/api/test/secret-scope.test.js @@ -229,6 +229,42 @@ test('changing the github view, user or filters keeps the stored token in scope' } }); +test('changing the git service or server URL takes the stored token out of scope', () => { + const github = getRegistry().github; + const saved = { githubService: 'gitea', githubUrl: 'https://git.a', githubUser: 'me', githubToken: 'STORED' }; + for (const over of [{ githubService: 'forgejo' }, { githubUrl: 'https://git.b' }]) { + assert.equal(widgetConfigMatchesSaved({ ...saved, ...over }, saved, github), false, JSON.stringify(over)); + } +}); + +test('a github widget saved before the service field keeps its token when the editor sends the default', () => { + const github = getRegistry().github; + const saved = { githubView: 'prs', githubUser: 'me', githubToken: 'STORED' }; + const posted = { githubService: 'github', githubView: 'prs', githubUser: 'me' }; + assert.equal(widgetConfigMatchesSaved(posted, saved, github), true); + assert.equal(alignSavedConfig(posted, saved, github).config.githubToken, 'STORED'); + assert.equal(widgetConfigMatchesSaved({ ...posted, githubService: 'gitea' }, saved, github), false); +}); + +test('a row or object saved before a defaulted field keeps its secret when the editor sends the default', () => { + const saved = { + services: [{ id: 'a', url: 'http://vpn', token: 'STORED' }], + vpn: { url: 'http://vpn', apiKey: 'K' }, + }; + const posted = { + services: [{ id: 'a', url: 'http://vpn', type: 'gluetun' }], + vpn: { url: 'http://vpn', service: 'gluetun' }, + }; + assert.equal(widgetConfigMatchesSaved(posted, saved, connections), true); + assert.equal(alignSavedConfig(posted, saved, connections).config.services[0].token, 'STORED'); + const moved = { ...posted, services: [{ ...posted.services[0], type: 'conduit' }] }; + assert.equal(alignSavedConfig(moved, saved, connections).config.services[0].token, undefined); + assert.equal( + widgetConfigMatchesSaved({ ...posted, vpn: { ...posted.vpn, service: 'netbird' } }, saved, connections), + false, + ); +}); + test('a field that reaches the request still takes the stored credential out of scope', () => { const r = getRegistry(); const cases = [ diff --git a/api/test/widget-defaults.test.js b/api/test/widget-defaults.test.js new file mode 100644 index 00000000..91123959 --- /dev/null +++ b/api/test/widget-defaults.test.js @@ -0,0 +1,117 @@ +const path = require('node:path'); +const fs = require('node:fs'); +const { tmpDir } = require('../test-support/tmp'); +const _tmp = tmpDir('wdef'); +process.env.CONFIG_PATH = path.join(_tmp, 'apps.json'); +process.env.WIDGETS_PATH = path.join(_tmp, 'widgets'); + +const MANIFEST = { + name: 'fixture', + label: 'Fixture', + sizes: ['medium'], + views: { main: { src: 'index.html' } }, + fields: [ + { + key: 'provider', + type: 'select', + label: 'Provider', + default: 'a', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ], + }, + { key: 'url', type: 'text', label: 'URL' }, + ], +}; +const _fx = path.join(_tmp, 'widgets', 'fixture'); +fs.mkdirSync(_fx, { recursive: true }); +fs.writeFileSync(path.join(_fx, 'widget.json'), JSON.stringify(MANIFEST)); +fs.writeFileSync(path.join(_fx, 'index.html'), ''); +fs.writeFileSync(path.join(_fx, 'data.js'), 'module.exports = async ctx => ({ config: ctx.config });\n'); + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { withManifestDefaults } = require('../src/widget-defaults'); + +const entry = fields => ({ manifest: { fields } }); + +test('an absent field gets its default and a set one keeps its value', () => { + const e = entry([ + { key: 'provider', type: 'select', default: 'a' }, + { key: 'on', type: 'toggle', default: false }, + ]); + assert.deepEqual(withManifestDefaults({}, e), { provider: 'a', on: false }); + assert.deepEqual(withManifestDefaults({ provider: 'b', on: true }, e), { provider: 'b', on: true }); + assert.deepEqual(withManifestDefaults({ provider: '' }, e), { provider: '', on: false }); +}); + +test('the input config is left unchanged', () => { + const cfg = { rows: [{ url: 'x' }] }; + const out = withManifestDefaults( + cfg, + entry([ + { key: 'provider', type: 'select', default: 'a' }, + { key: 'rows', type: 'group', fields: [{ key: 'type', type: 'select', default: 'cpu' }] }, + ]), + ); + assert.deepEqual(cfg, { rows: [{ url: 'x' }] }); + assert.deepEqual(out, { provider: 'a', rows: [{ url: 'x', type: 'cpu' }] }); +}); + +test('cosmetic, transient and secret fields get no default', () => { + const e = entry([ + { key: 'units', type: 'select', default: 'c', cosmetic: true }, + { key: 'search', type: 'text', default: '', transient: true }, + { key: 'key', type: 'secret', default: 'x' }, + ]); + assert.deepEqual(withManifestDefaults({}, e), {}); +}); + +test('a key declared with differing defaults, or once without one, gets none', () => { + assert.deepEqual( + withManifestDefaults( + {}, + entry([ + { key: 'p', type: 'select', default: 'a' }, + { key: 'p', type: 'select', default: 'b' }, + { key: 'q', type: 'select', default: 'a' }, + { key: 'q', type: 'select' }, + { key: 'r', type: 'select', default: 'a' }, + { key: 'r', type: 'select', default: 'a' }, + ]), + ), + { r: 'a' }, + ); +}); + +test('rows of a group and the keys of an object get their defaults', () => { + const e = entry([ + { key: 'slots', type: 'group', fields: [{ key: 'type', type: 'select', default: 'cpu' }] }, + { key: 'slots', type: 'group', fields: [{ key: 'type', type: 'select', default: 'cpu' }] }, + { key: 'vpn', type: 'object', fields: [{ key: 'service', type: 'select', default: 'gluetun' }] }, + ]); + assert.deepEqual(withManifestDefaults({ slots: [{}, { type: 'disk' }, 'junk'], vpn: {} }, e), { + slots: [{ type: 'cpu' }, { type: 'disk' }, 'junk'], + vpn: { service: 'gluetun' }, + }); +}); + +test('a default array is copied, not shared', () => { + const e = entry([{ key: 'list', type: 'multiselect', default: ['a'] }]); + withManifestDefaults({}, e).list.push('b'); + assert.deepEqual(withManifestDefaults({}, e).list, ['a']); +}); + +test('the data function reads a saved config with its defaults filled in', async () => { + const { getRegistry } = require('../src/widgets'); + const { getWidgetData } = require('../src/widget-data'); + const out = await getWidgetData( + { widgetType: 'fixture', widgetConfig: { url: 'http://x' } }, + getRegistry().fixture, + '', + new URLSearchParams(), + null, + ); + assert.deepEqual(out.body.config, { provider: 'a', url: 'http://x' }); +}); diff --git a/tsconfig.frontend.json b/tsconfig.frontend.json index d12aee19..d5b3399b 100644 --- a/tsconfig.frontend.json +++ b/tsconfig.frontend.json @@ -210,6 +210,12 @@ "/js/host-names.js?v=*": [ "./ui/js/host-names.js" ], + "/js/contribution-calendar.js": [ + "./ui/js/contribution-calendar.js" + ], + "/js/contribution-calendar.js?v=*": [ + "./ui/js/contribution-calendar.js" + ], "/js/service-link.js": [ "./ui/js/service-link.js" ], diff --git a/ui/js/contribution-calendar.js b/ui/js/contribution-calendar.js new file mode 100644 index 00000000..02e1c4d2 --- /dev/null +++ b/ui/js/contribution-calendar.js @@ -0,0 +1,31 @@ +function isoDay(d) { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +/** @param {unknown} slots [unix seconds, count] pairs @param {Date} [now] + @returns {{ weeks: { contributionDays: { contributionCount: number, date: string }[] }[], totalContributions: number }} */ +export function slotWeeks(slots, now = new Date()) { + const perDay = new Map(); + for (const s of Array.isArray(slots) ? slots : []) { + const t = Number(Array.isArray(s) ? s[0] : NaN); + const c = Number(Array.isArray(s) ? s[1] : NaN); + if (!Number.isFinite(t) || !Number.isFinite(c) || c <= 0) continue; + const day = isoDay(new Date(t * 1000)); + perDay.set(day, (perDay.get(day) || 0) + c); + } + const y = now.getFullYear(); + const m = now.getMonth(); + const today = now.getDate(); + const weeks = []; + let total = 0; + /* Noon, not midnight. Midnight does not exist on some daylight-saving days. */ + for (let d = today - 52 * 7 - now.getDay(); d <= today; d++) { + const day = new Date(y, m, d, 12); + if (day.getDay() === 0 || !weeks.length) weeks.push({ contributionDays: [] }); + const date = isoDay(day); + const count = perDay.get(date) || 0; + total += count; + weeks[weeks.length - 1].contributionDays.push({ contributionCount: count, date }); + } + return { weeks, totalContributions: total }; +} diff --git a/ui/test/contribution-calendar.test.mjs b/ui/test/contribution-calendar.test.mjs new file mode 100644 index 00000000..6e97d9b2 --- /dev/null +++ b/ui/test/contribution-calendar.test.mjs @@ -0,0 +1,59 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { slotWeeks } from '../js/contribution-calendar.js'; + +const TZ = process.env.TZ; +after(() => { + if (TZ === undefined) delete process.env.TZ; + else process.env.TZ = TZ; +}); + +const at = (y, m, d, h) => Math.floor(new Date(y, m - 1, d, h).getTime() / 1000); +const days = cal => cal.weeks.flatMap(w => w.contributionDays); + +test('slots are summed per local day, and the grid runs Sunday-first weeks up to today', () => { + process.env.TZ = 'UTC'; + const now = new Date(2026, 9, 24, 15); + const cal = slotWeeks( + [ + [at(2026, 10, 24, 9), 2], + [at(2026, 10, 24, 10), 3], + [at(2026, 10, 21, 12), 4], + [at(2025, 1, 1, 12), 9], + [at(2026, 10, 24, 11), 0], + ['x', 1], + null, + ], + now, + ); + const all = days(cal); + assert.equal(all.at(-1).date, '2026-10-24'); + assert.equal(all.at(-1).contributionCount, 5); + assert.equal(all.find(d => d.date === '2026-10-21').contributionCount, 4); + assert.equal(cal.totalContributions, 9); + assert.equal(cal.weeks.length, 53); + assert.ok(cal.weeks.every(w => new Date(`${w.contributionDays[0].date}T00:00:00Z`).getUTCDay() === 0)); +}); + +test("a slot counts on the viewer's day, not the server's", () => { + const t = Date.UTC(2026, 9, 24, 1) / 1000; + process.env.TZ = 'America/Los_Angeles'; + const west = days(slotWeeks([[t, 1]], new Date(2026, 9, 24, 12))); + assert.equal(west.find(d => d.contributionCount).date, '2026-10-23'); + process.env.TZ = 'UTC'; + const utc = days(slotWeeks([[t, 1]], new Date(2026, 9, 24, 12))); + assert.equal(utc.find(d => d.contributionCount).date, '2026-10-24'); +}); + +for (const zone of ['America/Santiago', 'Asia/Beirut', 'America/Havana']) { + test(`today stays on the grid in ${zone}, where daylight saving starts at midnight`, () => { + process.env.TZ = zone; + const now = new Date(); + const cal = slotWeeks([[Math.floor(now.getTime() / 1000), 4]], now); + const all = days(cal); + assert.equal(all.length, 52 * 7 + now.getDay() + 1); + assert.equal(new Set(all.map(d => d.date)).size, all.length); + assert.equal(all.at(-1).contributionCount, 4); + assert.equal(cal.totalContributions, 4); + }); +} diff --git a/ui/test/widget-toolbox-link.test.mjs b/ui/test/widget-toolbox-link.test.mjs index 01f06788..4798a147 100644 --- a/ui/test/widget-toolbox-link.test.mjs +++ b/ui/test/widget-toolbox-link.test.mjs @@ -125,10 +125,9 @@ test('the DNS link is described by its summary', () => { }); test('the pull request header link is named by its visible label first', () => { - assert.match( - widget('github/pullrequests.html'), - /'aria-label',\s*`\$\{wt\('ui\.pullRequests', 'Pull requests'\)\}, /, - ); + const src = widget('github/pullrequests.html'); + assert.match(src, /lbl\.textContent = noun;/); + assert.match(src, /'aria-label',\s*`\$\{noun\}, /); }); test('a pull request row without a number is named without one', () => { @@ -151,7 +150,7 @@ test('the pull request list holds list items', () => { test('the contributions summary uses the yearly total', () => { assert.match( widget('github/contributions.html'), - /const total = formatNumber\(Math\.max\(0, Number\(data\.totalContributions\) \|\| 0\)\);/, + /const total = formatNumber\(Math\.max\(0, Number\(cal\.totalContributions\) \|\| 0\)\);/, ); }); diff --git a/ui/widgets/github/contributions.html b/ui/widgets/github/contributions.html index c9953fce..866a0a39 100644 --- a/ui/widgets/github/contributions.html +++ b/ui/widgets/github/contributions.html @@ -3,7 +3,7 @@ -GitHub Contributions +Git Contributions -
+