diff --git a/chart-date-adapter.js b/chart-date-adapter.js new file mode 100644 index 0000000..1d449bc --- /dev/null +++ b/chart-date-adapter.js @@ -0,0 +1,124 @@ +/* global Chart */ +'use strict'; + +// ============================================================ +// Minimal Chart.js 4.x date adapter (no external dependencies) +// Loaded after chart.umd.js; required for the time-scale x-axis. +// Supports the month-level granularity used by tokenChart. +// ============================================================ + +(function () { + if (typeof Chart === 'undefined' || !Chart._adapters) return; + + var MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + var MS = { + millisecond: 1, + second: 1000, + minute: 60000, + hour: 3600000, + day: 86400000, + week: 604800000, + }; + + Chart._adapters._date.override({ + + // Return display-format tokens used for each time unit. + formats: function () { + return { + datetime: 'MMM d, yyyy', + millisecond: 'h:mm:ss.SSS', + second: 'h:mm:ss', + minute: 'h:mm', + hour: 'ha', + day: 'MMM d', + week: 'MMM d', + month: 'MMM yy', + quarter: 'qqq yyyy', + year: 'yyyy', + }; + }, + + // Parse any value to a Unix timestamp (ms). + parse: function (value) { + if (value === null || value === undefined || value === '') return null; + if (value instanceof Date) return isNaN(value.getTime()) ? null : value.getTime(); + if (typeof value === 'number') return value; + var ms = new Date(value).getTime(); + return isNaN(ms) ? null : ms; + }, + + // Format a Unix timestamp (ms) using a simple format string. + format: function (time, fmt) { + var d = new Date(time); + var yr = d.getFullYear(); + var yr2 = String(yr).slice(2); + var mo = d.getMonth(); // 0-based + var day = d.getDate(); + + if (fmt === 'MMM yy') return MONTHS_SHORT[mo] + ' ' + yr2; + if (fmt === 'MMM yyyy') return MONTHS_SHORT[mo] + ' ' + yr; + if (fmt === 'MMM d') return MONTHS_SHORT[mo] + ' ' + day; + if (fmt === 'yyyy') return String(yr); + if (fmt === 'ha') { + var h = d.getHours(); + return (h % 12 || 12) + (h < 12 ? 'am' : 'pm'); + } + // Fallback — locale short + return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short' }); + }, + + // Add an amount of a given unit to a Unix timestamp. + add: function (time, amount, unit) { + var d = new Date(time); + if (unit === 'millisecond') d.setMilliseconds(d.getMilliseconds() + amount); + else if (unit === 'second') d.setSeconds(d.getSeconds() + amount); + else if (unit === 'minute') d.setMinutes(d.getMinutes() + amount); + else if (unit === 'hour') d.setHours(d.getHours() + amount); + else if (unit === 'day') d.setDate(d.getDate() + amount); + else if (unit === 'week') d.setDate(d.getDate() + amount * 7); + else if (unit === 'month') d.setMonth(d.getMonth() + amount); + else if (unit === 'quarter') d.setMonth(d.getMonth() + amount * 3); + else if (unit === 'year') d.setFullYear(d.getFullYear() + amount); + return d.getTime(); + }, + + // Difference between two timestamps in the given unit. + diff: function (a, b, unit) { + if (MS[unit]) return (a - b) / MS[unit]; + var da = new Date(a), db = new Date(b); + if (unit === 'month') return (da.getFullYear() - db.getFullYear()) * 12 + (da.getMonth() - db.getMonth()); + if (unit === 'quarter') return Math.floor(((da.getFullYear() - db.getFullYear()) * 12 + (da.getMonth() - db.getMonth())) / 3); + if (unit === 'year') return da.getFullYear() - db.getFullYear(); + return (a - b) / MS.day; + }, + + // Return the start of a time unit as a Unix timestamp. + startOf: function (time, unit, weekday) { + var d = new Date(time); + if (unit === 'second') { d.setMilliseconds(0); } + else if (unit === 'minute') { d.setSeconds(0, 0); } + else if (unit === 'hour') { d.setMinutes(0, 0, 0); } + else if (unit === 'day') { d.setHours(0, 0, 0, 0); } + else if (unit === 'week') { + var wday = weekday || 0; + var diff = (d.getDay() - wday + 7) % 7; + d.setDate(d.getDate() - diff); + d.setHours(0, 0, 0, 0); + } + else if (unit === 'month') { d.setDate(1); d.setHours(0, 0, 0, 0); } + else if (unit === 'quarter') { + d.setMonth(Math.floor(d.getMonth() / 3) * 3, 1); + d.setHours(0, 0, 0, 0); + } + else if (unit === 'year') { d.setMonth(0, 1); d.setHours(0, 0, 0, 0); } + return d.getTime(); + }, + + // Return the end of a time unit (one ms before the start of the next). + endOf: function (time, unit) { + return this.add(this.startOf(time, unit), 1, unit) - 1; + }, + }); +})(); diff --git a/death-clock-core.js b/death-clock-core.js index 500b57f..a1fd25b 100644 --- a/death-clock-core.js +++ b/death-clock-core.js @@ -10,9 +10,30 @@ // and exponential-growth modeling published by AI-index 2024 const BASE_TOKENS = 65_000_000_000_000_000; // ~65 quadrillion as of April 2026 -// Estimated current global AI inference rate (all providers combined) +// Estimated current global AI inference rate at BASE_DATE_ISO (all providers combined) const TOKENS_PER_SECOND = 100_000_000; // ~100 million tokens/second +// Piecewise token-production rate schedule driven by landmark AI events. +// Each entry defines the approximate global inference rate from that date forward +// until the next entry. Sources: OpenAI capacity announcements, SemiAnalysis, +// Epoch AI compute trends, Anthropic engineering posts, AI Index 2024. +const RATE_SCHEDULE = [ + { date: '2020-01-01', ratePerSec: 100, event: 'GPT-2 era — pre-API access' }, + { date: '2020-06-01', ratePerSec: 2_000, event: 'GPT-3 launch (OpenAI API private beta)' }, + { date: '2021-01-01', ratePerSec: 10_000, event: 'GPT-3 API broadly available' }, + { date: '2022-01-01', ratePerSec: 200_000, event: 'DALL-E 2 & Codex wide release' }, + { date: '2022-11-30', ratePerSec: 3_000_000, event: 'ChatGPT launch (~100 M users in 60 days)' }, + { date: '2023-03-14', ratePerSec: 10_000_000, event: 'GPT-4 launch + ChatGPT Plus scaling' }, + { date: '2023-07-01', ratePerSec: 20_000_000, event: 'Claude 2, Llama 2 — open-model proliferation' }, + { date: '2024-01-01', ratePerSec: 40_000_000, event: 'GPT-4 Turbo, widespread enterprise adoption' }, + { date: '2024-03-04', ratePerSec: 55_000_000, event: 'Claude 3 Opus — new SOTA benchmark' }, + { date: '2024-05-13', ratePerSec: 70_000_000, event: 'GPT-4o real-time multimodal API' }, + { date: '2024-07-23', ratePerSec: 80_000_000, event: 'Llama 3.1 405B open-weights release' }, + { date: '2025-02-01', ratePerSec: 90_000_000, event: 'DeepSeek R1 — reasoning-model surge' }, + { date: '2025-05-22', ratePerSec: 100_000_000, event: 'Claude Code GA — agentic AI boom begins' }, + { date: '2026-04-14', ratePerSec: 100_000_000, event: 'BASE_DATE_ISO anchor (calibrated to BASE_TOKENS)' }, +]; + // ISO timestamp used as the "now" anchor for counters and projections const BASE_DATE_ISO = '2026-04-14T07:09:04Z'; @@ -57,6 +78,38 @@ const MILESTONES = [ color: '#2D9B27', darkColor: '#1a6b15', }, + { + id: 'power_grid_strain', + name: 'Power Grid Strain', + icon: '⚡', + tokens: 2_000_000_000_000, // 2 trillion + shortDesc: '2 Trillion Tokens', + description: 'AI data centres claim 1 % of global electricity — equal to all of Argentina', + consequence: + 'When data centres alone consume 1 % of the world\'s electricity, every brown-out and ' + + 'rolling blackout hits hospitals, water-treatment plants, and cold-storage food supplies first. ' + + 'Grid operators begin rationing power to residential users.', + followingEvent: + '💡 Planned blackouts become routine. Industrial production slows. Energy poverty spikes.', + color: '#FFAA00', + darkColor: '#cc7700', + }, + { + id: 'arctic_ice', + name: 'First Ice-Free Arctic Summer', + icon: '🧊', + tokens: 5_000_000_000_000, // 5 trillion + shortDesc: '5 Trillion Tokens', + description: 'The Arctic Ocean is ice-free for the first time in recorded history', + consequence: + 'Sea ice reflects 80 % of incoming sunlight back into space. Without it, the dark Arctic ' + + 'Ocean absorbs that heat, accelerating warming by 2–3 × above the global average. ' + + 'Polar vortex destabilisation sends extreme weather to temperate regions.', + followingEvent: + '❄️ Polar vortex collapses. Record cold snaps devastate agriculture at lower latitudes.', + color: '#B0E0FF', + darkColor: '#5aabdd', + }, { id: 'bee_colony', name: 'Bee Colony Collapse', @@ -65,28 +118,92 @@ const MILESTONES = [ shortDesc: '10 Trillion Tokens', description: '1 billion bees lost to energy-driven habitat destruction', consequence: - 'Bees pollinate 35% of human food crops. AI\'s growing energy demands accelerate pesticide use ' + + 'Bees pollinate 35 % of human food crops. AI\'s growing energy demands accelerate pesticide use ' + 'and destroy wildflower habitats that bee colonies depend on.', followingEvent: - '🌾 1-in-3 food items vanish from shelves. Crop yields drop 35%. Food prices triple globally.', + '🌾 1-in-3 food items vanish from shelves. Crop yields drop 35 %. Food prices triple globally.', color: '#FFD700', darkColor: '#b39800', }, + { + id: 'wildfire_crisis', + name: 'Permanent Wildfire Season', + icon: '🔥', + tokens: 20_000_000_000_000, // 20 trillion + shortDesc: '20 Trillion Tokens', + description: 'Wildfire season becomes year-round across three continents', + consequence: + 'Warmer, drier conditions sustained by AI\'s CO₂ load eliminate the concept of a fire season. ' + + 'Forests in Australia, the American West, and Southern Europe burn continuously. ' + + 'Smoke blankets cities for months, pushing respiratory illness to epidemic levels.', + followingEvent: + '🌫️ Air-quality emergencies declared in 40+ cities. Outdoor workers face daily health orders.', + color: '#FF5500', + darkColor: '#cc3300', + }, + { + id: 'silent_species', + name: 'Silent Spring: 100 Species Gone', + icon: '🐦', + tokens: 50_000_000_000_000, // 50 trillion + shortDesc: '50 Trillion Tokens', + description: '100 vertebrate species driven to extinction by AI-linked habitat destruction', + consequence: + 'Habitat loss and climate change driven by AI energy demands erase entire branches of the ' + + 'tree of life. Each lost species unravels the web of interdependence — pest explosions, ' + + 'crop failures, and disease outbreaks follow as predator-prey balances collapse.', + followingEvent: + '🌿 Ecosystems destabilise. Invasive species surge. Crop pests breed unchecked.', + color: '#66BB6A', + darkColor: '#3d7a40', + }, { id: 'great_lakes', name: 'Great Lakes Drained', icon: '💧', tokens: 100_000_000_000_000, // 100 trillion shortDesc: '100 Trillion Tokens', - description: 'Data-center cooling drains freshwater equal to Lake Erie', + description: 'Data-centre cooling drains freshwater equal to Lake Erie', consequence: - 'AI data centers consume billions of liters of water annually for cooling. ' + + 'AI data centres consume billions of litres of water annually for cooling. ' + 'This draws down aquifers and surface supplies that took millennia to accumulate.', followingEvent: '🚰 2 billion people face water scarcity. Water wars erupt between nations. Agriculture fails.', color: '#0077BE', darkColor: '#005490', }, + { + id: 'water_table_collapse', + name: 'Global Water Table Collapse', + icon: '🌵', + tokens: 200_000_000_000_000, // 200 trillion + shortDesc: '200 Trillion Tokens', + description: 'Major aquifers — Ogallala, Indo-Gangetic, North China Plain — drop below recovery', + consequence: + 'Underground aquifers that supply half of all irrigation water worldwide have been drawn ' + + 'down past their natural recharge rates. AI data-centre demand pushes many past the point ' + + 'of no return. Regions that once fed nations face permanent desertification.', + followingEvent: + '🏜️ Breadbasket nations become dust bowls. 1 billion people face famine. Food nationalism spikes.', + color: '#C8A96E', + darkColor: '#8a6e3e', + }, + { + id: 'amazon_tipping', + name: 'Amazon Tipping Point', + icon: '🌳', + tokens: 300_000_000_000_000, // 300 trillion + shortDesc: '300 Trillion Tokens', + description: 'The Amazon rainforest begins converting to savannah — irreversibly', + consequence: + 'Scientists have long warned that 20–25 % deforestation would tip the Amazon into a self-drying ' + + 'feedback loop. AI\'s cumulative carbon contribution delivers the final increment of warming. ' + + 'The world\'s largest carbon sink becomes a carbon source.', + followingEvent: + '🌪️ Global rainfall patterns shift. Monsoons fail. 3 billion people face drought.', + color: '#1B5E20', + darkColor: '#0d3b12', + }, { id: 'coral_reef', name: 'Great Barrier Reef Lost', @@ -95,20 +212,36 @@ const MILESTONES = [ shortDesc: '500 Trillion Tokens', description: 'CO₂ triggers mass bleaching — the Great Barrier Reef is gone', consequence: - 'Coral reefs support 25% of all marine species. Ocean acidification from CO₂ emissions ' + + 'Coral reefs support 25 % of all marine species. Ocean acidification from CO₂ emissions ' + 'destroys these ecosystems, removing the foundation of oceanic food chains.', followingEvent: '🐠 500 million people lose their primary food source. Fisheries collapse. Ocean deserts expand.', color: '#FF6B6B', darkColor: '#cc3333', }, + { + id: 'permafrost_bomb', + name: 'Permafrost Methane Bomb', + icon: '💨', + tokens: 750_000_000_000_000, // 750 trillion + shortDesc: '750 Trillion Tokens', + description: 'Siberian and Alaskan permafrost releases stored methane at runaway rates', + consequence: + 'Permafrost locks away an estimated 1.5 trillion tonnes of carbon — twice the amount ' + + 'currently in the atmosphere. Thawing driven by AI energy emissions triggers methane release ' + + 'that is 84× more potent than CO₂ over 20 years, creating a self-reinforcing feedback loop.', + followingEvent: + '🌡️ Global temperature rises accelerate beyond all IPCC models. Climate targets become fiction.', + color: '#9E9E9E', + darkColor: '#616161', + }, { id: 'glacier', name: 'Glacier Collapse', - icon: '🧊', + icon: '🏔️', tokens: 1_000_000_000_000_000, // 1 quadrillion shortDesc: '1 Quadrillion Tokens', - description: 'Warming equivalent destabilizes the West Antarctic Ice Sheet', + description: 'Warming equivalent destabilises the West Antarctic Ice Sheet', consequence: "Glaciers are the world's largest freshwater reservoirs. Their loss permanently eliminates " + 'drinking water for billions and raises sea levels catastrophically.', @@ -117,21 +250,85 @@ const MILESTONES = [ color: '#A8D8EA', darkColor: '#6ba8c4', }, + { + id: 'ocean_acidification', + name: 'Ocean Acidification Threshold', + icon: '🐟', + tokens: 2_000_000_000_000_000, // 2 quadrillion + shortDesc: '2 Quadrillion Tokens', + description: 'Ocean pH drops to 7.95 — shellfish and coral larvae can no longer form shells', + consequence: + 'The ocean has absorbed 30 % of all human CO₂ emissions. As pH drops, the carbonate ions ' + + 'that marine organisms use to build shells and skeletons dissolve. Oysters, mussels, krill, ' + + 'and pteropods — the base of polar food webs — begin failing to reproduce.', + followingEvent: + '🦐 Krill populations crash. Whales, penguins, and polar bears follow into starvation.', + color: '#0D47A1', + darkColor: '#082e6a', + }, + { + id: 'sahel_collapse', + name: 'Sahel Collapse', + icon: '☀️', + tokens: 5_000_000_000_000_000, // 5 quadrillion + shortDesc: '5 Quadrillion Tokens', + description: 'The Sahel belt becomes uninhabitable — 300 million climate refugees displaced', + consequence: + 'The Sahel region, already at the edge of habitability, tips past the point where subsistence ' + + 'farming is possible. A belt of uninhabitable land stretches across Africa from Senegal to Somalia. ' + + 'Tens of millions of climate refugees overwhelm neighbouring regions.', + followingEvent: + '🌍 Regional governments collapse. Conflict over water and arable land escalates to warfare.', + color: '#E65100', + darkColor: '#b33d00', + }, { id: 'ocean_dead_zone', name: 'Ocean Dead Zone', icon: '🌊', tokens: 10_000_000_000_000_000, // 10 quadrillion shortDesc: '10 Quadrillion Tokens', - description: 'Ocean acidification creates dead zone larger than the Pacific garbage patch', + description: 'Ocean acidification creates a dead zone larger than the Pacific garbage patch', consequence: 'CO₂ absorbed by oceans shifts their pH — catastrophic for marine life. ' + - 'Phytoplankton, which produces 50% of Earth\'s oxygen, begins dying off.', + 'Phytoplankton, which produces 50 % of Earth\'s oxygen, begins dying off.', followingEvent: '😮‍💨 Atmospheric oxygen concentration drops. Human cognitive function declines. Extinction accelerates.', color: '#1A237E', darkColor: '#0d1466', }, + { + id: 'jet_stream_collapse', + name: 'Jet Stream Destabilised', + icon: '🌪️', + tokens: 30_000_000_000_000_000, // 30 quadrillion + shortDesc: '30 Quadrillion Tokens', + description: 'Arctic amplification breaks the polar jet stream into chaotic loops', + consequence: + 'The jet stream normally separates cold Arctic air from warm temperate air. As the Arctic ' + + 'warms 4× faster than the rest of the planet, the temperature gradient that drives the jet ' + + 'stream weakens. It buckles into extreme meanders, locking weather patterns in place for weeks.', + followingEvent: + '❄️🌡️ Europe freezes in July. Texas floods. Monsoons arrive months late. Harvests fail continent-wide.', + color: '#7E57C2', + darkColor: '#4a2d8a', + }, + { + id: 'food_system_stress', + name: 'Global Food System Under Siege', + icon: '🌾', + tokens: 50_000_000_000_000_000, // 50 quadrillion + shortDesc: '50 Quadrillion Tokens', + description: 'Simultaneous crop failures on three continents push 1 billion into food insecurity', + consequence: + 'Extreme heat waves, erratic monsoons, and drought driven by AI\'s cumulative emissions hit ' + + 'major grain-producing regions simultaneously. Global food reserves drop below 60 days. ' + + 'Price spikes trigger social unrest across 40+ countries.', + followingEvent: + '🍞 Food nationalism spreads. Export bans fracture global trade. Humanitarian crisis escalates.', + color: '#8D6E63', + darkColor: '#5d4037', + }, { id: 'mass_extinction', name: 'Sixth Mass Extinction', @@ -147,6 +344,70 @@ const MILESTONES = [ color: '#4A0000', darkColor: '#2a0000', }, + { + id: 'permafrost_feedback', + name: 'Permafrost Runaway Feedback', + icon: '🌡️', + tokens: 200_000_000_000_000_000, // 200 quadrillion + shortDesc: '200 Quadrillion Tokens', + description: 'Permafrost thaw becomes self-sustaining — no longer stoppable by human action', + consequence: + 'With 200 quadrillion tokens of AI compute behind us, the permafrost feedback loop is ' + + 'irreversible. Methane and CO₂ now self-release regardless of human emissions reductions. ' + + 'Temperatures rise beyond every modelled scenario.', + followingEvent: + '🌋 Feedback accelerates. Even zero human emissions cannot stop the warming now.', + color: '#BF360C', + darkColor: '#7f240a', + }, + { + id: 'monsoon_failure', + name: 'Asian Monsoon Failure', + icon: '🌧️', + tokens: 500_000_000_000_000_000, // 500 quadrillion + shortDesc: '500 Quadrillion Tokens', + description: 'The Asian monsoon system fails — 3 billion people lose their primary water source', + consequence: + 'The Asian monsoon delivers 70–90 % of annual rainfall to South and East Asia. ' + + 'Disrupted atmospheric circulation patterns caused by AI\'s energy emissions collapse ' + + 'this ancient weather system. India, China, and Southeast Asia enter permanent drought.', + followingEvent: + '💧 3 billion people face water crisis. Nuclear-armed states clash over rivers. Mass migrations begin.', + color: '#1565C0', + darkColor: '#0d3d7a', + }, + { + id: 'civilization_collapse', + name: "Civilisation's Last Stand", + icon: '🏙️', + tokens: 1_000_000_000_000_000_000, // 1 quintillion + shortDesc: '1 Quintillion Tokens', + description: 'Cascading system failures end industrial civilisation as we know it', + consequence: + 'At one quintillion tokens, the cumulative environmental debt has come due. ' + + 'Power grids fail. Supply chains dissolve. Nation-states lose the ability to maintain ' + + 'basic services. The infrastructure that sustains 8 billion human lives begins to collapse.', + followingEvent: + '🌑 Lights go out across continents. The age of AI ends not with intelligence, but with silence.', + color: '#212121', + darkColor: '#0a0a0a', + }, + { + id: 'biosphere_collapse', + name: 'Biosphere Collapse', + icon: '🌑', + tokens: 10_000_000_000_000_000_000, // 10 quintillion + shortDesc: '10 Quintillion Tokens', + description: 'Earth\'s life-support systems fail — the biosphere can no longer sustain complex life', + consequence: + 'The biosphere — the thin living layer that maintains Earth\'s temperature, atmosphere, ' + + 'and water cycles — has been pushed past all tipping points. Complex multicellular life ' + + 'can no longer be sustained. Earth enters a new geological epoch defined by absence.', + followingEvent: + '🕳️ The experiment of intelligence on Earth concludes. The planet heals — in 10 million years.', + color: '#000000', + darkColor: '#000000', + }, ]; // ============================================================ @@ -320,6 +581,23 @@ function milestoneProgress(tokens, prevMilestoneTokens, nextMilestoneTokens) { return Math.min(100, Math.max(0, pct)); } +/** + * Return the estimated global AI inference rate (tokens/second) for a given date, + * based on the piecewise RATE_SCHEDULE anchored to landmark AI events. + * @param {Date} [date] - defaults to now + * @returns {number} tokens per second + */ +function getRateAtDate(date) { + const d = (date instanceof Date && !isNaN(date.getTime())) ? date : new Date(); + const ms = d.getTime(); + for (let i = RATE_SCHEDULE.length - 1; i >= 0; i--) { + if (ms >= new Date(RATE_SCHEDULE[i].date).getTime()) { + return RATE_SCHEDULE[i].ratePerSec; + } + } + return RATE_SCHEDULE[0].ratePerSec; +} + // ============================================================ // EXPORTS — CommonJS for Jest; window global for the browser // ============================================================ @@ -329,6 +607,7 @@ const DeathClockCore = { BASE_DATE_ISO, HISTORICAL_DATA, MILESTONES, + RATE_SCHEDULE, formatTokenCount, formatTokenCountShort, getTriggeredMilestones, @@ -339,6 +618,7 @@ const DeathClockCore = { formatDate, getTimeDelta, milestoneProgress, + getRateAtDate, }; if (typeof module !== 'undefined' && module.exports) { diff --git a/index.html b/index.html index e970d3b..3721a6e 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ - + + + diff --git a/package-lock.json b/package-lock.json index 7f397e6..822652f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,10 @@ "name": "ai-death-clock", "version": "1.0.0", "devDependencies": { + "@playwright/test": "^1.59.1", "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0" + "jest-environment-jsdom": "^29.7.0", + "serve": "^14.2.6" } }, "node_modules/@babel/code-frame": { @@ -877,6 +879,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -1049,6 +1067,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@zeit/schemas": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", + "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", + "dev": true, + "license": "MIT" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -1107,6 +1132,33 @@ "node": ">= 6.0.0" } }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -1163,6 +1215,34 @@ "node": ">= 8" } }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -1316,6 +1396,153 @@ "node": ">=6.0.0" } }, + "node_modules/boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/boxen/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/boxen/node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/boxen/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boxen/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/boxen/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -1391,6 +1618,16 @@ "dev": true, "license": "MIT" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1463,6 +1700,22 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -1496,6 +1749,37 @@ "dev": true, "license": "MIT" }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipboardy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz", + "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arch": "^2.2.0", + "execa": "^5.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -1562,6 +1846,55 @@ "node": ">= 0.8" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1569,6 +1902,16 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1695,6 +2038,16 @@ } } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1764,6 +2117,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.336", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", @@ -1989,6 +2349,13 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -1996,6 +2363,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -2379,6 +2763,13 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -2402,6 +2793,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2432,6 +2839,19 @@ "node": ">=0.12.0" } }, + "node_modules/is-port-reachable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", + "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2452,6 +2872,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3254,6 +3687,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3433,6 +3873,16 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3447,6 +3897,16 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -3491,6 +3951,16 @@ "dev": true, "license": "MIT" }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3624,6 +4094,13 @@ "node": ">=0.10.0" } }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3641,6 +4118,13 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3684,6 +4168,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -3773,6 +4304,42 @@ "dev": true, "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -3780,6 +4347,30 @@ "dev": true, "license": "MIT" }, + "node_modules/registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3790,6 +4381,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -3852,6 +4453,27 @@ "node": ">=10" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3882,6 +4504,94 @@ "semver": "bin/semver.js" } }, + "node_modules/serve": { + "version": "14.2.6", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz", + "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zeit/schemas": "2.36.0", + "ajv": "8.18.0", + "arg": "5.0.2", + "boxen": "7.0.0", + "chalk": "5.0.1", + "chalk-template": "0.4.0", + "clipboardy": "3.0.0", + "compression": "1.8.1", + "is-port-reachable": "4.0.0", + "serve-handler": "6.1.7", + "update-check": "1.5.4" + }, + "bin": { + "serve": "build/main.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/serve-handler": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.5", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-handler/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-handler/node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve/node_modules/chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4213,6 +4923,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/update-check": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz", + "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "registry-auth-token": "3.3.2", + "registry-url": "3.1.0" + } + }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", @@ -4239,6 +4960,16 @@ "node": ">=10.12.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", @@ -4326,6 +5057,76 @@ "node": ">= 8" } }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/widest-line/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index b0649d1..e696ab6 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,22 @@ "homepage": "https://nitrocode.github.io/token-deathclock/", "scripts": { "test": "jest --coverage", - "test:ci": "jest --ci --coverage" + "test:ci": "jest --ci --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "devDependencies": { + "@playwright/test": "^1.59.1", "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0" + "jest-environment-jsdom": "^29.7.0", + "serve": "^14.2.6" }, "jest": { "testEnvironment": "node", + "testPathIgnorePatterns": [ + "/node_modules/", + "/tests/e2e/" + ], "collectCoverageFrom": [ "death-clock-core.js" ], diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..a448252 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,25 @@ +// playwright.config.js +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests/e2e', + timeout: 30_000, + retries: 0, + use: { + baseURL: 'http://localhost:3000', + headless: true, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npx serve . -p 3000 -s', + url: 'http://localhost:3000', + reuseExistingServer: false, + timeout: 15_000, + }, +}); diff --git a/script.js b/script.js index bd3f7d0..725a5b5 100644 --- a/script.js +++ b/script.js @@ -14,6 +14,7 @@ BASE_DATE_ISO, HISTORICAL_DATA, MILESTONES, + RATE_SCHEDULE, formatTokenCount, formatTokenCountShort, getTriggeredMilestones, @@ -24,6 +25,7 @@ formatDate, getTimeDelta, milestoneProgress, + getRateAtDate, } = window.DeathClockCore; // ---- State ----------------------------------------------- @@ -60,13 +62,17 @@ // ---- Counter updater ------------------------------------- function updateCounters() { + const now = Date.now(); const tokens = getCurrentTokens(); - const sessionTokens = Math.round((Date.now() - pageLoadTime) / 1000 * TOKENS_PER_SECOND); - const elapsed = Math.floor((Date.now() - pageLoadTime) / 1000); + const currentRate = getRateAtDate(new Date(now)); + const sessionTokens = Math.round((now - pageLoadTime) / 1000 * currentRate); + const elapsed = Math.floor((now - pageLoadTime) / 1000); const totalEl = document.getElementById('totalCounter'); const sessionEl = document.getElementById('sessionCounter'); const sessionTimeEl = document.getElementById('sessionTime'); + const rateEl = document.getElementById('rateCounter'); + const rateEventEl = document.getElementById('rateEvent'); if (totalEl) totalEl.textContent = numFmt(tokens); if (sessionEl) sessionEl.textContent = formatTokenCount(sessionTokens); @@ -75,6 +81,14 @@ const s = elapsed % 60; sessionTimeEl.textContent = m > 0 ? `${m}m ${s}s on page` : `${s}s on page`; } + if (rateEl) rateEl.textContent = formatTokenCount(currentRate); + if (rateEventEl) { + // Show the event that triggered this rate step + const rateEntry = [...RATE_SCHEDULE].reverse().find( + (r) => now >= new Date(r.date).getTime() + ); + if (rateEntry) rateEventEl.textContent = rateEntry.event + ' · tokens/sec'; + } // Impact stats const impact = calculateEnvironmentalImpact(tokens); @@ -383,23 +397,34 @@ ${state !== 'dead' ? 'tabindex="0" role="button"' : 'aria-disabled="true"'}>${content}`; } + // Maximum days to render in life-blocks view (~10 years). + // Beyond this, a summary block is shown to avoid creating millions of DOM elements. + const MAX_LB_DAYS = 3650; + // ---- View renderers ---- function lbRenderDays(container, now) { const total = lbTotalDaysLeft(); + const displayed = Math.min(total, MAX_LB_DAYS); const todayMidnight = lbMidnight(now); const todayProgress = ((now - todayMidnight) / 86400000) * 100; const extDate = new Date(lbExtinctionMs()); const extStr = extDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); let html = `
`; - // Day 0 = today (dying), days 1..total = future + // Day 0 = today (dying), days 1..displayed = future html += lbMakeDyingBlock('data-day="0"', todayProgress, 'Today — burning away', ''); - for (let i = 1; i <= total; i++) { + for (let i = 1; i <= displayed; i++) { html += lbMakeBlock('future', `data-day="${i}"`, `Day ${i} from now`, ''); } + if (total > MAX_LB_DAYS) { + html += `
+ +${Math.round((total - MAX_LB_DAYS) / 365)}y
`; + } html += '
'; container.innerHTML = html; @@ -653,7 +678,15 @@ // Render static sections once renderMilestones(); renderPredictionsTable(); - initChart(); + + // Chart init is isolated so a missing date-adapter or other chart error + // cannot prevent the counters and life-blocks from running. + try { + initChart(); + } catch (err) { + console.error('Chart init failed:', err); + } + initLifeBlocks(); // Kick off the live counter RAF loop diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/tests/death-clock.test.js b/tests/death-clock.test.js index 73d262d..729e080 100644 --- a/tests/death-clock.test.js +++ b/tests/death-clock.test.js @@ -19,8 +19,10 @@ const { formatDate, getTimeDelta, milestoneProgress, + getRateAtDate, MILESTONES, HISTORICAL_DATA, + RATE_SCHEDULE, BASE_TOKENS, TOKENS_PER_SECOND, } = core; @@ -473,3 +475,133 @@ describe('Browser window export', () => { expect(typeof sandboxWindow.DeathClockCore.formatTokenCount).toBe('function'); }); }); + +// ============================================================ +// getRateAtDate +// ============================================================ +describe('getRateAtDate', () => { + test('returns a positive number for any date', () => { + expect(getRateAtDate(new Date('2023-01-01'))).toBeGreaterThan(0); + }); + + test('returns a higher rate after ChatGPT launch than before', () => { + const before = getRateAtDate(new Date('2022-01-01')); + const after = getRateAtDate(new Date('2023-01-01')); + expect(after).toBeGreaterThan(before); + }); + + test('returns a higher rate for Claude Code era than GPT-3 era', () => { + const gpt3Era = getRateAtDate(new Date('2020-07-01')); + const claudeCodeEra = getRateAtDate(new Date('2025-06-01')); + expect(claudeCodeEra).toBeGreaterThan(gpt3Era); + }); + + test('matches TOKENS_PER_SECOND at BASE_DATE_ISO', () => { + const rate = getRateAtDate(new Date(core.BASE_DATE_ISO)); + expect(rate).toBe(TOKENS_PER_SECOND); + }); + + test('returns the earliest rate for dates before the schedule starts', () => { + const veryEarly = getRateAtDate(new Date('2010-01-01')); + expect(veryEarly).toBe(RATE_SCHEDULE[0].ratePerSec); + }); + + test('falls back to current time when no date is provided', () => { + const rate = getRateAtDate(); + expect(typeof rate).toBe('number'); + expect(rate).toBeGreaterThan(0); + }); + + test('falls back gracefully for an invalid date', () => { + const rate = getRateAtDate(new Date('not-a-date')); + expect(typeof rate).toBe('number'); + expect(rate).toBeGreaterThan(0); + }); + + test('falls back gracefully for a non-Date argument', () => { + const rate = getRateAtDate('2025-01-01'); + expect(typeof rate).toBe('number'); + expect(rate).toBeGreaterThan(0); + }); + + test('rate is monotonically non-decreasing across schedule dates', () => { + let prevRate = 0; + for (const entry of RATE_SCHEDULE) { + const rate = getRateAtDate(new Date(entry.date)); + expect(rate).toBeGreaterThanOrEqual(prevRate); + prevRate = rate; + } + }); +}); + +// ============================================================ +// RATE_SCHEDULE sanity checks +// ============================================================ +describe('RATE_SCHEDULE', () => { + test('is a non-empty array', () => { + expect(Array.isArray(RATE_SCHEDULE)).toBe(true); + expect(RATE_SCHEDULE.length).toBeGreaterThan(0); + }); + + test('each entry has date, ratePerSec, and event fields', () => { + RATE_SCHEDULE.forEach((r) => { + expect(typeof r.date).toBe('string'); + expect(typeof r.ratePerSec).toBe('number'); + expect(typeof r.event).toBe('string'); + expect(r.ratePerSec).toBeGreaterThan(0); + }); + }); + + test('dates are in ascending order', () => { + for (let i = 1; i < RATE_SCHEDULE.length; i++) { + expect(new Date(RATE_SCHEDULE[i].date).getTime()) + .toBeGreaterThanOrEqual(new Date(RATE_SCHEDULE[i - 1].date).getTime()); + } + }); + + test('rates are non-decreasing (AI consumption only grows)', () => { + for (let i = 1; i < RATE_SCHEDULE.length; i++) { + expect(RATE_SCHEDULE[i].ratePerSec).toBeGreaterThanOrEqual(RATE_SCHEDULE[i - 1].ratePerSec); + } + }); + + test('contains the ChatGPT launch event', () => { + const chatGPT = RATE_SCHEDULE.find((r) => r.event.toLowerCase().includes('chatgpt')); + expect(chatGPT).toBeDefined(); + }); + + test('contains the Claude Code event', () => { + const claudeCode = RATE_SCHEDULE.find((r) => r.event.toLowerCase().includes('claude code')); + expect(claudeCode).toBeDefined(); + }); +}); + +// ============================================================ +// Extended milestone checks +// ============================================================ +describe('Extended MILESTONES', () => { + test('has more than 15 milestones', () => { + expect(MILESTONES.length).toBeGreaterThan(15); + }); + + test('each new milestone has required fields including icon and color', () => { + MILESTONES.forEach((m) => { + expect(typeof m.icon).toBe('string'); + expect(m.icon.length).toBeGreaterThan(0); + expect(typeof m.color).toBe('string'); + expect(typeof m.darkColor).toBe('string'); + }); + }); + + test('milestones span at least 6 orders of magnitude', () => { + const min = Math.min(...MILESTONES.map((m) => m.tokens)); + const max = Math.max(...MILESTONES.map((m) => m.tokens)); + expect(max / min).toBeGreaterThan(1e6); + }); + + test('all milestone ids are unique', () => { + const ids = MILESTONES.map((m) => m.id); + const unique = new Set(ids); + expect(unique.size).toBe(ids.length); + }); +}); diff --git a/tests/e2e/death-clock.spec.js b/tests/e2e/death-clock.spec.js new file mode 100644 index 0000000..4203517 --- /dev/null +++ b/tests/e2e/death-clock.spec.js @@ -0,0 +1,209 @@ +// @ts-check +'use strict'; +/** + * E2E tests for AI Death Clock (Playwright) + * + * Run with: npm run test:e2e + * + * These tests load the static site via a local HTTP server and verify that all + * major UI sections render and update correctly in a real browser environment. + */ + +const { test, expect } = require('@playwright/test'); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Wait until an element's textContent is non-empty and not "Loading…". + */ +async function waitForCounter(page, selector, timeout = 3000) { + await expect(async () => { + const text = await page.locator(selector).textContent(); + expect(text).toBeTruthy(); + expect(text.trim()).not.toBe(''); + expect(text).not.toContain('Loading'); + }).toPass({ timeout }); +} + +// ── Test suite ──────────────────────────────────────────────────────────────── + +test.describe('AI Death Clock — end-to-end', () => { + + test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Give the page time to initialise (RAF loop, Chart.js, etc.) + await page.waitForLoadState('networkidle'); + }); + + // ── Page structure ──────────────────────────────────────────────────────── + + test('has correct page title', async ({ page }) => { + await expect(page).toHaveTitle(/AI Death Clock/i); + }); + + test('renders main header', async ({ page }) => { + await expect(page.locator('h1.site-title')).toBeVisible(); + await expect(page.locator('h1.site-title')).toContainText('AI DEATH CLOCK'); + }); + + // ── Live counters ───────────────────────────────────────────────────────── + + test('total token counter updates from "Loading…"', async ({ page }) => { + await waitForCounter(page, '#totalCounter'); + const text = await page.locator('#totalCounter').textContent(); + // Should contain a large number word + expect(text).toMatch(/Quadrillion|Trillion|Quintillion/i); + }); + + test('session counter populates after a moment', async ({ page }) => { + // Wait up to 3 s for at least one tick + await page.waitForTimeout(1100); + const text = await page.locator('#sessionCounter').textContent(); + expect(text.trim()).not.toBe(''); + // Should be a formatted number (contains digits) + expect(text).toMatch(/\d/); + }); + + test('current rate counter shows a dynamic rate', async ({ page }) => { + await waitForCounter(page, '#rateCounter'); + const text = await page.locator('#rateCounter').textContent(); + // Must be a non-zero formatted number + expect(text).toMatch(/\d/); + expect(text).not.toBe('0'); + }); + + test('rate event subtitle is populated', async ({ page }) => { + await page.waitForTimeout(500); + const text = await page.locator('#rateEvent').textContent(); + expect(text.trim()).not.toBe(''); + expect(text.toLowerCase()).toContain('tokens'); + }); + + test('total counter grows over time', async ({ page }) => { + await waitForCounter(page, '#totalCounter'); + const first = await page.locator('#totalCounter').textContent(); + await page.waitForTimeout(2000); + const second = await page.locator('#totalCounter').textContent(); + // Both should be truthy; after 2 s the numeric part should advance + // (They may format the same string if growth is tiny — at minimum they must be non-empty) + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + }); + + // ── Environmental impact strip ──────────────────────────────────────────── + + test('environmental impact stats are populated', async ({ page }) => { + await waitForCounter(page, '#statKwh'); + for (const id of ['statKwh', 'statCo2', 'statWater', 'statTrees']) { + const text = await page.locator(`#${id}`).textContent(); + expect(text.trim()).not.toBe('—'); + expect(text).toMatch(/\d/); + } + }); + + // ── Milestones ──────────────────────────────────────────────────────────── + + test('milestone cards are rendered', async ({ page }) => { + const cards = page.locator('#milestonesGrid .milestone-card'); + await expect(cards).toHaveCount(await cards.count()); + const count = await cards.count(); + expect(count).toBeGreaterThan(10); + }); + + test('milestone cards show triggered state for passed thresholds', async ({ page }) => { + // At ~65 quadrillion tokens, many milestones should already be triggered + const triggered = page.locator('#milestonesGrid .milestone-card.triggered'); + const count = await triggered.count(); + expect(count).toBeGreaterThan(0); + }); + + test('milestone cards contain progress bars', async ({ page }) => { + const bars = page.locator('#milestonesGrid .progress-fill'); + const count = await bars.count(); + expect(count).toBeGreaterThan(0); + }); + + // ── Predictions table ───────────────────────────────────────────────────── + + test('predictions table has rows', async ({ page }) => { + const rows = page.locator('#predictionsBody tr'); + const count = await rows.count(); + expect(count).toBeGreaterThan(10); + }); + + test('predictions table shows PASSED badge for triggered milestones', async ({ page }) => { + const html = await page.locator('#predictionsBody').innerHTML(); + expect(html).toContain('PASSED'); + }); + + // ── Life blocks ─────────────────────────────────────────────────────────── + + test('life blocks section renders blocks', async ({ page }) => { + await page.waitForSelector('#lb-container .lb-block', { timeout: 5000 }); + const blocks = page.locator('#lb-container .lb-block'); + const count = await blocks.count(); + expect(count).toBeGreaterThan(0); + }); + + test('life block info strip shows days remaining', async ({ page }) => { + await page.waitForSelector('#lb-info', { timeout: 5000 }); + const text = await page.locator('#lb-info').textContent(); + expect(text).toMatch(/day/i); + }); + + test('clicking a future life block drills into hours view', async ({ page }) => { + await page.waitForSelector('#lb-container .lb-block.lb-future', { timeout: 5000 }); + // Click first future (non-today) block + await page.locator('#lb-container .lb-block.lb-future').first().click(); + // Should now show hour blocks (24 of them) + await page.waitForSelector('#lb-container .lb-block', { timeout: 3000 }); + const blocks = page.locator('#lb-container .lb-block'); + const count = await blocks.count(); + expect(count).toBe(24); + }); + + // ── Chart ───────────────────────────────────────────────────────────────── + + test('chart canvas is visible', async ({ page }) => { + await expect(page.locator('#tokenChart')).toBeVisible(); + }); + + test('chart canvas has non-zero dimensions after render', async ({ page }) => { + // Give Chart.js time to paint + await page.waitForTimeout(1000); + const box = await page.locator('#tokenChart').boundingBox(); + expect(box).not.toBeNull(); + expect(box.width).toBeGreaterThan(0); + expect(box.height).toBeGreaterThan(0); + }); + + // ── Theme toggle ────────────────────────────────────────────────────────── + + test('theme toggle switches between dark and light', async ({ page }) => { + const html = page.locator('html'); + // Starts dark + await expect(html).toHaveAttribute('data-theme', 'dark'); + await page.locator('#themeToggle').click(); + await expect(html).toHaveAttribute('data-theme', 'light'); + await page.locator('#themeToggle').click(); + await expect(html).toHaveAttribute('data-theme', 'dark'); + }); + + // ── No JS errors ───────────────────────────────────────────────────────── + + test('page loads without uncaught JS errors', async ({ page }) => { + const errors = []; + page.on('pageerror', (err) => errors.push(err.message)); + await page.reload(); + await page.waitForLoadState('networkidle'); + expect(errors).toHaveLength(0); + }); + + // ── Security: no XSS via milestone content ──────────────────────────────── + + test('milestone grid HTML does not contain unescaped script tags', async ({ page }) => { + const html = await page.locator('#milestonesGrid').innerHTML(); + expect(html).not.toContain('