From f8e97623b766cb31a49fb49df63344ff0ca6b11b Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Fri, 15 May 2026 17:24:36 +0200 Subject: [PATCH 01/22] Refactor storage key for school ID in ConfirmschoolPage --- src/app/confirmschool/confirmschool.page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/confirmschool/confirmschool.page.ts b/src/app/confirmschool/confirmschool.page.ts index b2bf6e70..e45b83c4 100644 --- a/src/app/confirmschool/confirmschool.page.ts +++ b/src/app/confirmschool/confirmschool.page.ts @@ -140,7 +140,7 @@ export class ConfirmschoolPage implements OnInit{ this.storage.set('version', environment.app_version); //this.storage.set('country_code', c.country); this.storage.set('country_code', this.selectedCountry); - this.storage.set('school_id', this.school.school_id); + this.storage.set('schoolId', this.school.school_id); this.storage.set('schoolInfo', JSON.stringify(this.school)); // Set first-time visit flags for new registration flow From 12113515a990721d275fb402dce6b43254fb4360 Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Fri, 7 Aug 2026 16:30:45 +0200 Subject: [PATCH 02/22] fix: repair spec imports broken by earlier renames indexed-db.service.spec.ts imported LocalStorageService from a file that no longer exists; invalidlocation.page.spec.ts imported SchoolnotfoundPage from invalidlocation.page, which exports InvalidLocationPage. Both broke compilation of the whole karma suite. Co-Authored-By: Claude Fable 5 --- src/app/invalidlocation/invalidlocation.page.spec.ts | 12 ++++++------ src/app/services/indexed-db.service.spec.ts | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/app/invalidlocation/invalidlocation.page.spec.ts b/src/app/invalidlocation/invalidlocation.page.spec.ts index 09639600..f4318d32 100644 --- a/src/app/invalidlocation/invalidlocation.page.spec.ts +++ b/src/app/invalidlocation/invalidlocation.page.spec.ts @@ -2,16 +2,16 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { IonicModule } from '@ionic/angular'; import { RouterTestingModule } from "@angular/router/testing"; import { TranslateModule } from '@ngx-translate/core'; -import { SchoolnotfoundPage } from './invalidlocation.page'; +import { InvalidLocationPage } from './invalidlocation.page'; import { ActivatedRoute } from "@angular/router"; -describe('SchoolnotfoundPage', () => { - let component: SchoolnotfoundPage; - let fixture: ComponentFixture; +describe('InvalidLocationPage', () => { + let component: InvalidLocationPage; + let fixture: ComponentFixture; let activatedroute: ActivatedRoute; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [ SchoolnotfoundPage ], + declarations: [ InvalidLocationPage ], imports: [ IonicModule.forRoot(), RouterTestingModule, @@ -19,7 +19,7 @@ describe('SchoolnotfoundPage', () => { ] }).compileComponents(); - fixture = TestBed.createComponent(SchoolnotfoundPage); + fixture = TestBed.createComponent(InvalidLocationPage); activatedroute = TestBed.inject(ActivatedRoute); component = fixture.componentInstance; fixture.detectChanges(); diff --git a/src/app/services/indexed-db.service.spec.ts b/src/app/services/indexed-db.service.spec.ts index ba1dbd43..ac73d02b 100644 --- a/src/app/services/indexed-db.service.spec.ts +++ b/src/app/services/indexed-db.service.spec.ts @@ -1,13 +1,13 @@ import { TestBed } from '@angular/core/testing'; -import { LocalStorageService } from './local-storage.service'; +import { IndexedDBService } from './indexed-db.service'; -describe('LocalStorageService', () => { - let service: LocalStorageService; +describe('IndexedDBService', () => { + let service: IndexedDBService; beforeEach(() => { TestBed.configureTestingModule({}); - service = TestBed.inject(LocalStorageService); + service = TestBed.inject(IndexedDBService); }); it('should be created', () => { From 20d725d8b92bb834efe92d9d11d0dcbefebc092a Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Fri, 7 Aug 2026 16:30:55 +0200 Subject: [PATCH 03/22] feat: retry scheduled tests for the whole slot window, paced by failure type Replaces the 3-retries-every-15-min cap (~45 min of coverage in a 4-hour window) with a policy that retries until the test completes or the window ends, paced by why the attempt failed: - No network: retry every minute. These retries generate no test traffic, and getNetInfo() now gets a navigator.onLine pre-check so offline ticks don't hit the IP-info services either. - Test failed with network up: exponential backoff min(60s * 1.2^n, 10 min), since each attempt consumes real bandwidth. The semaphore now persists retryAttempts (total tries in the slot), backoffLevel (the backoff exponent, reset when the network comes back after a no-network failure) and lastFailReason. getNetInfo() exceptions - which previously escaped decide() and skipped rescheduling entirely - are now caught and treated as no-network. Plan: project-memory/plans/0005-scheduler-retry-upgrade-v2.0.4.md (giga repo) Co-Authored-By: Claude Fable 5 --- src/app/services/schedule.service.spec.ts | 216 ++++++++++++++++++++-- src/app/services/schedule.service.ts | 97 +++++++--- 2 files changed, 271 insertions(+), 42 deletions(-) diff --git a/src/app/services/schedule.service.spec.ts b/src/app/services/schedule.service.spec.ts index ca77c94b..a8da9b71 100644 --- a/src/app/services/schedule.service.spec.ts +++ b/src/app/services/schedule.service.spec.ts @@ -1,25 +1,215 @@ -import { TestBed } from '@angular/core/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { ScheduleService } from './schedule.service'; -import { Network } from '@awesome-cordova-plugins/network/ngx'; describe('ScheduleService', () => { let service: ScheduleService; + let store: Record; + let storageService: any; + let measurementClientService: any; + let settingsService: any; + let sharedService: any; + let networkService: any; + + const MINUTE = 60 * 1000; + + // 2026-08-07 09:00 local time — inside slot A (08:00–12:00) + const NOW = new Date(2026, 7, 7, 9, 0, 0); + const SLOT_A_START = new Date(2026, 7, 7, 8, 0, 0).getTime(); + const SLOT_A_END = new Date(2026, 7, 7, 12, 0, 0).getTime(); + + const slotASemaphore = (overrides: any = {}) => ({ + start: SLOT_A_START, + end: SLOT_A_END, + choice: new Date(2026, 7, 7, 8, 30, 0).getTime(), + intervalType: 'daily', + retryAttempts: 0, + backoffLevel: 0, + ...overrides, + }); + + const savedSemaphore = () => JSON.parse(store.scheduleSemaphore); beforeEach(() => { - TestBed.configureTestingModule({ - imports: [], - providers: [ - Network, - provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting() - ] -}); - service = TestBed.inject(ScheduleService); + jasmine.clock().install(); + jasmine.clock().mockDate(NOW); + + store = {}; + storageService = { + get: (key: string) => store[key], + set: (key: string, value: string) => { + store[key] = value; + }, + }; + measurementClientService = { + runTest: jasmine.createSpy('runTest').and.resolveTo(undefined), + }; + settingsService = { + get: jasmine.createSpy('get').and.resolveTo(true), + }; + sharedService = { + broadcast: jasmine.createSpy('broadcast'), + on: jasmine.createSpy('on'), + }; + networkService = { + getNetInfo: jasmine.createSpy('getNetInfo').and.resolveTo({ ip: '1.2.3.4' }), + }; + + service = new ScheduleService( + storageService, + measurementClientService, + settingsService, + sharedService, + networkService + ); + }); + + afterEach(() => { + jasmine.clock().uninstall(); }); it('should be created', () => { expect(service).toBeTruthy(); }); + + describe('no-network retries (fixed 1-minute pace)', () => { + it('reschedules 1 minute ahead when getNetInfo rejects, without raising backoffLevel', async () => { + networkService.getNetInfo.and.rejectWith(new Error('offline')); + + await service.decide(slotASemaphore()); + + const sem = savedSemaphore(); + expect(sem.choice).toBe(NOW.getTime() + MINUTE); + expect(sem.retryAttempts).toBe(1); + expect(sem.backoffLevel).toBe(0); + expect(sem.lastFailReason).toBe('no-network'); + expect(measurementClientService.runTest).not.toHaveBeenCalled(); + }); + + it('keeps the 1-minute pace across consecutive offline ticks', async () => { + networkService.getNetInfo.and.rejectWith(new Error('offline')); + + await service.decide(slotASemaphore()); + jasmine.clock().tick(2 * MINUTE); + await service.decide(savedSemaphore()); + + const sem = savedSemaphore(); + expect(sem.choice).toBe(NOW.getTime() + 2 * MINUTE + MINUTE); + expect(sem.retryAttempts).toBe(2); + expect(sem.backoffLevel).toBe(0); + }); + + it('treats a null getNetInfo result as no network', async () => { + networkService.getNetInfo.and.resolveTo(null); + + await service.decide(slotASemaphore()); + + expect(savedSemaphore().lastFailReason).toBe('no-network'); + }); + }); + + describe('failed-test retries (exponential backoff)', () => { + it('backs off 60s * 1.2^n and increments backoffLevel on each failure', async () => { + measurementClientService.runTest.and.rejectWith(new Error('test broke')); + + await service.decide(slotASemaphore()); + let sem = savedSemaphore(); + expect(sem.choice).toBe(NOW.getTime() + MINUTE); // 60s * 1.2^0 + expect(sem.retryAttempts).toBe(1); + expect(sem.backoffLevel).toBe(1); + expect(sem.lastFailReason).toBe('test-failed'); + + jasmine.clock().tick(2 * MINUTE); + await service.decide(sem); + sem = savedSemaphore(); + expect(sem.choice).toBe(NOW.getTime() + 2 * MINUTE + 1.2 * MINUTE); // 60s * 1.2^1 + expect(sem.retryAttempts).toBe(2); + expect(sem.backoffLevel).toBe(2); + }); + + it('caps the delay at 10 minutes', async () => { + measurementClientService.runTest.and.rejectWith(new Error('test broke')); + + await service.decide(slotASemaphore({ backoffLevel: 30 })); + + const sem = savedSemaphore(); + expect(sem.choice).toBe(NOW.getTime() + 10 * MINUTE); + expect(sem.backoffLevel).toBe(31); + }); + }); + + describe('network recovery', () => { + it('resets the backoff after a no-network failure once the network is back', async () => { + measurementClientService.runTest.and.rejectWith(new Error('test broke')); + + await service.decide( + slotASemaphore({ backoffLevel: 5, lastFailReason: 'no-network' }) + ); + + const sem = savedSemaphore(); + expect(sem.backoffLevel).toBe(1); // reset to 0, then this failure bumps it + expect(sem.choice).toBe(NOW.getTime() + MINUTE); // 60s * 1.2^0 + }); + }); + + describe('success and window expiry', () => { + it('saves lastMeasurement and clears the semaphore on success', async () => { + await service.decide(slotASemaphore()); + + expect(measurementClientService.runTest).toHaveBeenCalledWith('daily'); + expect(store.lastMeasurement).toBe(NOW.getTime().toString()); + expect(savedSemaphore()).toEqual({}); + }); + + it('clears the semaphore when the window already ended', async () => { + jasmine.clock().mockDate(new Date(2026, 7, 7, 12, 30, 0)); + + await service.decide(slotASemaphore()); + + expect(savedSemaphore()).toEqual({}); + expect(measurementClientService.runTest).not.toHaveBeenCalled(); + }); + + it('gives up instead of rescheduling when the retry would land past the window end', async () => { + networkService.getNetInfo.and.rejectWith(new Error('offline')); + jasmine.clock().mockDate(new Date(SLOT_A_END)); + + await service.decide(slotASemaphore()); + + expect(savedSemaphore()).toEqual({}); + }); + + it('clamps the rescheduled choice to the window end', async () => { + networkService.getNetInfo.and.rejectWith(new Error('offline')); + jasmine.clock().mockDate(new Date(SLOT_A_END - 30 * 1000)); + + await service.decide(slotASemaphore()); + + expect(savedSemaphore().choice).toBe(SLOT_A_END); + }); + }); + + describe('legacy semaphores', () => { + it('handles a pre-2.0.4 semaphore without the new fields', async () => { + measurementClientService.runTest.and.rejectWith(new Error('test broke')); + const legacy = slotASemaphore(); + delete legacy.backoffLevel; + + await service.decide(legacy); + + const sem = savedSemaphore(); + expect(sem.choice).toBe(NOW.getTime() + MINUTE); + expect(sem.backoffLevel).toBe(1); + }); + }); + + describe('createSlotSemaphore via scheduleInitializers', () => { + it('creates semaphores with retry fields initialised', async () => { + store.lastMeasurement = '0'; + const sem = await service.scheduleInitializers('daily'); + + expect(sem.retryAttempts).toBe(0); + expect(sem.backoffLevel).toBe(0); + expect(sem.choice).toBeGreaterThanOrEqual(sem.start); + expect(sem.choice).toBeLessThanOrEqual(sem.end); + }); + }); }); diff --git a/src/app/services/schedule.service.ts b/src/app/services/schedule.service.ts index 3d2623c9..44726bd2 100644 --- a/src/app/services/schedule.service.ts +++ b/src/app/services/schedule.service.ts @@ -16,8 +16,10 @@ export class ScheduleService { private readonly SLOT_B_START = 12; // 12 PM private readonly SLOT_C_START = 16; // 4 PM private readonly SLOT_DURATION = 4 * 60 * 60 * 1000; // 4 hours in milliseconds - private readonly MAX_RETRY_ATTEMPTS = 3; // Maximum number of retry attempts - private readonly RETRY_DELAY = 15 * 60 * 1000; // 15 minutes in milliseconds + private readonly NO_NETWORK_RETRY_DELAY = 60 * 1000; // 1 minute in milliseconds + private readonly RETRY_BASE_DELAY = 60 * 1000; // 1 minute in milliseconds + private readonly RETRY_BACKOFF_FACTOR = 1.2; + private readonly RETRY_MAX_DELAY = 10 * 60 * 1000; // 10 minutes in milliseconds private readonly STARTUP_TEST_DELAY = 15 * 60 * 1000; // 15 minutes in milliseconds private readonly STARTUP_TEST_KEY = 'lastStartupTest'; private readonly STARTUP_TEST_SCHEDULED_KEY = 'startupTestScheduled'; @@ -114,7 +116,14 @@ export class ScheduleService { console.log( `Scheduling for slot ${slotName}: ${new Date(choice).toISOString()}` ); - return { start, end, choice, intervalType: 'daily', retryAttempts: 0 }; + return { + start, + end, + choice, + intervalType: 'daily', + retryAttempts: 0, + backoffLevel: 0, + }; } // Get the timestamp of the last measurement @@ -146,10 +155,10 @@ export class ScheduleService { if (scheduleSemaphore.choice && currentTime > scheduleSemaphore.choice) { console.log("It's time to run the measurement"); - const networkInfo = await this.networkService.getNetInfo(); + const networkInfo = await this.getNetworkInfoSafe(); if (!networkInfo) { console.log('Network not available, rescheduling measurement.'); - await this.rescheduleFailedMeasurement(scheduleSemaphore); + await this.rescheduleFailedMeasurement(scheduleSemaphore, 'no-network'); return; } if (currentTime > scheduleSemaphore.end) { @@ -157,6 +166,10 @@ export class ScheduleService { await this.setSemaphore({}); return; } + if (scheduleSemaphore.lastFailReason === 'no-network') { + // Network is back: the failed-test backoff starts over + scheduleSemaphore.backoffLevel = 0; + } try { console.log('Running test...'); @@ -171,43 +184,69 @@ export class ScheduleService { await this.setSemaphore({}); } catch (error) { console.error('Measurement failed:', error); - await this.rescheduleFailedMeasurement(scheduleSemaphore); + await this.rescheduleFailedMeasurement(scheduleSemaphore, 'test-failed'); } } else { console.log('Not time to run measurement yet'); } } - private async rescheduleFailedMeasurement(scheduleSemaphore: any) { + // getNetInfo() throws when fully offline (its geojs fallback fails too); + // treat both the throw and an empty result as "no network" + private async getNetworkInfoSafe(): Promise { + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + console.log('navigator.onLine is false, skipping network check'); + return null; + } + try { + return await this.networkService.getNetInfo(); + } catch (error) { + console.log('Network check failed:', error); + return null; + } + } + + private async rescheduleFailedMeasurement( + scheduleSemaphore: any, + reason: 'no-network' | 'test-failed' + ) { console.log( - 'Rescheduling failed measurement. Current semaphore:', + `Rescheduling failed measurement (${reason}). Current semaphore:`, scheduleSemaphore ); const currentTime = Date.now(); - const retryAttempts = (scheduleSemaphore.retryAttempts || 0) + 1; - if ( - retryAttempts <= this.MAX_RETRY_ATTEMPTS && - currentTime < scheduleSemaphore.end - ) { - const rescheduleTime = currentTime + this.RETRY_DELAY; - const newSemaphore = { - ...scheduleSemaphore, - choice: Math.min(rescheduleTime, scheduleSemaphore.end), - retryAttempts, - }; - await this.setSemaphore(newSemaphore); - console.log( - `Rescheduled measurement for ${new Date( - newSemaphore.choice - ).toISOString()}` - ); - } else { - console.log( - 'Max retry attempts reached or slot ended. Scheduling for next slot.' - ); + if (currentTime >= scheduleSemaphore.end) { + console.log('Slot ended. Scheduling for next slot.'); await this.setSemaphore({}); + return; } + + // no-network retries are free (no test traffic), so they stay at a fixed + // 1-minute pace; failed tests consume real bandwidth, so they back off + const backoffLevel = scheduleSemaphore.backoffLevel || 0; + const delay = + reason === 'no-network' + ? this.NO_NETWORK_RETRY_DELAY + : Math.min( + this.RETRY_BASE_DELAY * + Math.pow(this.RETRY_BACKOFF_FACTOR, backoffLevel), + this.RETRY_MAX_DELAY + ); + + const newSemaphore = { + ...scheduleSemaphore, + choice: Math.min(currentTime + delay, scheduleSemaphore.end), + retryAttempts: (scheduleSemaphore.retryAttempts || 0) + 1, + backoffLevel: reason === 'test-failed' ? backoffLevel + 1 : backoffLevel, + lastFailReason: reason, + }; + await this.setSemaphore(newSemaphore); + console.log( + `Rescheduled measurement for ${new Date( + newSemaphore.choice + ).toISOString()}` + ); } async getSemaphore() { From ae5d6ba481c0df9e8a2ad818a53f7f9b95b980d4 Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Fri, 7 Aug 2026 17:24:47 +0200 Subject: [PATCH 04/22] feat: flag sync-recovered measurements and record their planned slot Measurements that fail the realtime upload already get queued in IndexedDB and re-sent by the periodic sync, but on the backend they were indistinguishable from realtime uploads. The payload now carries: - upload_failed: false on realtime uploads; the copy queued in IndexedDB is saved with true, so the sync delivers it flagged without any sync changes. - scheduled_slot: 'A' | 'B' | 'C' for slot tests, 'startup' for the daily launch test, null for manual runs. - scheduled_at: the originally planned run time (the semaphore keeps it separately from choice, which moves with every retry). The sync payload also stops leaking IndexedDB bookkeeping fields (id, status, createdAt); the old code stripped isSynced, which measurement records never had. Plan: project-memory/plans/0006-local-test-storage-flag-v2.0.4.md (giga repo) Co-Authored-By: Claude Fable 5 --- .../services/measurement-client.service.ts | 26 ++++++++++++---- src/app/services/schedule.service.spec.ts | 31 +++++++++++++++++-- src/app/services/schedule.service.ts | 22 +++++++++++-- src/app/services/sync.service.ts | 3 +- src/app/services/upload.service.ts | 14 ++++++++- 5 files changed, 83 insertions(+), 13 deletions(-) diff --git a/src/app/services/measurement-client.service.ts b/src/app/services/measurement-client.service.ts index 96829006..578e99d6 100644 --- a/src/app/services/measurement-client.service.ts +++ b/src/app/services/measurement-client.service.ts @@ -78,15 +78,24 @@ export class MeasurementClientService { private sharedService: SharedService ) {} - async runTest(notes = 'manual'): Promise { + async runTest( + notes = 'manual', + scheduleContext: { slot: string | null; scheduledAt: number | null } = null + ): Promise { console.log('Starting ndt7 test', ndt7); this.retryAttempts = 0; - await this.runTestWithRetry(notes); + await this.runTestWithRetry(notes, scheduleContext); } - private async runTestWithRetry(notes = 'manual'): Promise { + private async runTestWithRetry( + notes = 'manual', + scheduleContext: { slot: string | null; scheduledAt: number | null } = null + ): Promise { this.broadcastMeasurementStatus('onstart', {}); - const measurementRecord = this.initializeMeasurementRecord(notes); + const measurementRecord = this.initializeMeasurementRecord( + notes, + scheduleContext + ); // Get Windows username, installed path, and WiFi connections const windowsUsername = await this.getWindowsUsername(); @@ -130,14 +139,17 @@ export class MeasurementClientService { // Wait a bit before retrying await new Promise((resolve) => setTimeout(resolve, 2000)); - return this.runTestWithRetry(notes); + return this.runTestWithRetry(notes, scheduleContext); } else { this.broadcastMeasurementStatus('onError', { error: error.message }); } } } - private initializeMeasurementRecord(notes: string) { + private initializeMeasurementRecord( + notes: string, + scheduleContext: { slot: string | null; scheduledAt: number | null } = null + ) { return { timestamp: Date.now(), results: {}, @@ -152,6 +164,8 @@ export class MeasurementClientService { windowsUsername: '', installedPath: '', wifiConnections: null, + scheduledSlot: scheduleContext?.slot ?? null, + scheduledAt: scheduleContext?.scheduledAt ?? null, }; } diff --git a/src/app/services/schedule.service.spec.ts b/src/app/services/schedule.service.spec.ts index a8da9b71..ebfa0e96 100644 --- a/src/app/services/schedule.service.spec.ts +++ b/src/app/services/schedule.service.spec.ts @@ -16,10 +16,14 @@ describe('ScheduleService', () => { const SLOT_A_START = new Date(2026, 7, 7, 8, 0, 0).getTime(); const SLOT_A_END = new Date(2026, 7, 7, 12, 0, 0).getTime(); + const SLOT_A_CHOICE = new Date(2026, 7, 7, 8, 30, 0).getTime(); + const slotASemaphore = (overrides: any = {}) => ({ start: SLOT_A_START, end: SLOT_A_END, - choice: new Date(2026, 7, 7, 8, 30, 0).getTime(), + choice: SLOT_A_CHOICE, + scheduledAt: SLOT_A_CHOICE, + slot: 'A', intervalType: 'daily', retryAttempts: 0, backoffLevel: 0, @@ -154,11 +158,32 @@ describe('ScheduleService', () => { it('saves lastMeasurement and clears the semaphore on success', async () => { await service.decide(slotASemaphore()); - expect(measurementClientService.runTest).toHaveBeenCalledWith('daily'); + expect(measurementClientService.runTest).toHaveBeenCalledWith('daily', { + slot: 'A', + scheduledAt: SLOT_A_CHOICE, + }); expect(store.lastMeasurement).toBe(NOW.getTime().toString()); expect(savedSemaphore()).toEqual({}); }); + it('keeps the originally planned time in scheduledAt across retries', async () => { + measurementClientService.runTest.and.rejectWith(new Error('test broke')); + await service.decide(slotASemaphore()); + + const rescheduled = savedSemaphore(); + expect(rescheduled.choice).not.toBe(SLOT_A_CHOICE); + expect(rescheduled.scheduledAt).toBe(SLOT_A_CHOICE); + + measurementClientService.runTest.and.resolveTo(undefined); + jasmine.clock().tick(2 * MINUTE); + await service.decide(rescheduled); + + expect(measurementClientService.runTest).toHaveBeenCalledWith('daily', { + slot: 'A', + scheduledAt: SLOT_A_CHOICE, + }); + }); + it('clears the semaphore when the window already ended', async () => { jasmine.clock().mockDate(new Date(2026, 7, 7, 12, 30, 0)); @@ -208,6 +233,8 @@ describe('ScheduleService', () => { expect(sem.retryAttempts).toBe(0); expect(sem.backoffLevel).toBe(0); + expect(sem.slot).toBe('A'); + expect(sem.scheduledAt).toBe(sem.choice); expect(sem.choice).toBeGreaterThanOrEqual(sem.start); expect(sem.choice).toBeLessThanOrEqual(sem.end); }); diff --git a/src/app/services/schedule.service.ts b/src/app/services/schedule.service.ts index 44726bd2..7c9c8d3a 100644 --- a/src/app/services/schedule.service.ts +++ b/src/app/services/schedule.service.ts @@ -120,6 +120,10 @@ export class ScheduleService { start, end, choice, + // choice moves with every retry; scheduledAt keeps the originally + // planned run time for the measurement record + scheduledAt: choice, + slot: slotName, intervalType: 'daily', retryAttempts: 0, backoffLevel: 0, @@ -174,7 +178,12 @@ export class ScheduleService { try { console.log('Running test...'); await this.measurementClientService.runTest( - scheduleSemaphore.intervalType + scheduleSemaphore.intervalType, + { + slot: scheduleSemaphore.slot || null, + scheduledAt: + scheduleSemaphore.scheduledAt || scheduleSemaphore.choice, + } ); console.log('Measurement completed successfully'); await this.storageService.set( @@ -364,14 +373,21 @@ export class ScheduleService { // Run the startup test private async runStartupTest() { console.log('Running startup test'); - const networkInfo = await this.networkService.getNetInfo(); + const networkInfo = await this.getNetworkInfoSafe(); if (!networkInfo) { console.log('Network not available for startup test, skipping.'); return; } try { - await this.measurementClientService.runTest('startup'); + const scheduledFor = parseInt( + await this.storageService.get(this.STARTUP_TEST_SCHEDULED_KEY), + 10 + ); + await this.measurementClientService.runTest('startup', { + slot: 'startup', + scheduledAt: isNaN(scheduledFor) ? null : scheduledFor, + }); console.log('Startup test completed successfully'); this.storageService.set('lastMeasurement', Date.now().toString()); this.storageService.set(this.STARTUP_TEST_KEY, Date.now().toString()); diff --git a/src/app/services/sync.service.ts b/src/app/services/sync.service.ts index 7a0ddf55..aae82d5b 100644 --- a/src/app/services/sync.service.ts +++ b/src/app/services/sync.service.ts @@ -59,7 +59,8 @@ export class SyncService { } private async postMeasurementsWithRetry(batch: any[]): Promise { - const payload = batch.map(({ isSynced, ...rest }) => rest); + // Strip IndexedDB-local bookkeeping fields before posting + const payload = batch.map(({ id, status, createdAt, ...rest }) => rest); try { await this.http diff --git a/src/app/services/upload.service.ts b/src/app/services/upload.service.ts index 800eb04f..6e53826c 100644 --- a/src/app/services/upload.service.ts +++ b/src/app/services/upload.service.ts @@ -173,6 +173,15 @@ export class UploadService { measurement['installed_path'] = record.installedPath || null; measurement['wifi_connections'] = record.wifiConnections || null; + // Schedule context: which slot/time this measurement was planned for + // (null for manual runs). upload_failed flips to true only when the + // realtime upload fails and the record is queued for later sync. + measurement['scheduled_slot'] = record.scheduledSlot || null; + measurement['scheduled_at'] = record.scheduledAt + ? new Date(record.scheduledAt).toISOString() + : null; + measurement['upload_failed'] = false; + // Add API key if configured. if (apiKey != '') { @@ -196,7 +205,10 @@ export class UploadService { tap((data) => data), catchError(async (error) => { console.error('Upload failed, saving to IndexedDB...', error); - await this.indexedDB.saveMeasurement(measurementWithGeo); + await this.indexedDB.saveMeasurement({ + ...measurementWithGeo, + upload_failed: true, + }); return of({ savedLocally: true, error }); }) ) From b1b65e5570a9cfa70b58da6e3ea542dad30731e5 Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Mon, 10 Aug 2026 13:08:17 +0200 Subject: [PATCH 05/22] feat: use @m-lab/ndt7 npm package instead of vendored copies The app bundled a modified copy of @m-lab/ndt7 0.0.6 (2022) under src/assets/js/ndt/. The copy differed from upstream in two ways: it added client_name=giga-meter to the metadata sent to M-Lab, and it removed the Node-only polyfills (require('ws') etc.) that broke webpack builds. Upstream 0.1.5 makes both edits unnecessary: the polyfills are gone and config.metadata is the official way to send client_name. So this switches to the package: - @m-lab/ndt7 bumped 0.0.6 -> ^0.1.5 (it was already a dependency, unused). - measurement-client imports the package and passes metadata: { client_name: 'giga-meter', client_version: app_version }. - The worker files ship from node_modules via an angular.json assets glob; runtime paths are unchanged. - Vendored ndt7.js and both workers deleted (NDT5 legacy files left alone). - src/types/ndt7.d.ts declares the untyped package. Error propagation is structurally identical between the copy and 0.1.5 (same throw on locate fetch failure, same 'Could not understand response' string), so the locate-error retry classification keeps working; specs cover the config passed to ndt7.test and the retry classification. Also brings 0.1.5's improved timeouts: 10s to connect + 12s of test after connecting, instead of 12s total. Plan: project-memory/plans/0007-ndt7-npm-package-migration.md (giga repo) Co-Authored-By: Claude Fable 5 --- angular.json | 5 + package-lock.json | 51 +-- package.json | 2 +- .../measurement-client.service.spec.ts | 72 ++++ .../services/measurement-client.service.ts | 10 +- src/assets/js/ndt/ndt7-download-worker.js | 99 ----- src/assets/js/ndt/ndt7-upload-worker.js | 168 --------- src/assets/js/ndt/ndt7.js | 337 ------------------ src/types/ndt7.d.ts | 44 +++ 9 files changed, 141 insertions(+), 647 deletions(-) delete mode 100644 src/assets/js/ndt/ndt7-download-worker.js delete mode 100644 src/assets/js/ndt/ndt7-upload-worker.js delete mode 100644 src/assets/js/ndt/ndt7.js create mode 100644 src/types/ndt7.d.ts diff --git a/angular.json b/angular.json index 27f2ac56..2cf60c21 100644 --- a/angular.json +++ b/angular.json @@ -28,6 +28,11 @@ "input": "src/assets", "output": "assets" }, + { + "glob": "ndt7-*-worker.js", + "input": "node_modules/@m-lab/ndt7/src", + "output": "assets/js/ndt" + }, { "glob": "**/*.svg", "input": "node_modules/ionicons/dist/ionicons/svg", diff --git a/package-lock.json b/package-lock.json index 118f08fa..31f73905 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@cloudflare/speedtest": "^1.4.1", "@electron/remote": "^2.1.2", "@ionic/angular": "^6.0.3", - "@m-lab/ndt7": "^0.0.6", + "@m-lab/ndt7": "^0.1.5", "@ngx-translate/core": "^14.0.0", "@ngx-translate/http-loader": "^7.0.0", "@sentry/browser": "^5.5.0", @@ -10158,42 +10158,12 @@ ] }, "node_modules/@m-lab/ndt7": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@m-lab/ndt7/-/ndt7-0.0.6.tgz", - "integrity": "sha512-vOnbJETYUqg8Tj6V3tLshj7Nch4SmuJGPVmedIkC7V/x7LTWNHU98RdYcZbbhMWoPOrqGwhOylkqZZtjXdi0BA==", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@m-lab/ndt7/-/ndt7-0.1.5.tgz", + "integrity": "sha512-PlfHJ4wBUSt9yMWo2NUQmXWmmTVNaSGK914qh+G+IfLp4KBCxGlL1zDzrP7gucoSMppHoP0x0yLxaxB2t/A9jg==", "license": "Apache-2.0", - "dependencies": { - "node-fetch": "^2.6.0", - "workerjs": "^0.1.1", - "ws": "^8.5.0" - }, "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "bufferutil": "^4.0.6", - "utf-8-validate": "^5.0.8" - } - }, - "node_modules/@m-lab/ndt7/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "node": ">=18" } }, "node_modules/@malept/cross-spawn-promise": { @@ -14544,9 +14514,11 @@ "version": "4.0.9", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -24560,6 +24532,7 @@ "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, "license": "MIT", "optional": true, "bin": { @@ -32837,9 +32810,11 @@ "version": "5.0.10", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -33806,12 +33781,6 @@ "node": ">=0.10.0" } }, - "node_modules/workerjs": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/workerjs/-/workerjs-0.1.1.tgz", - "integrity": "sha512-fMlithUrdswVB/bDtrncuXeuIOwc4hS+LXsAZNjdcpoOjU0rw1TFV2I5IlCwx6hysU2IveI8uWlkf5mTAQXHcw==", - "license": "BSD-3-Clause" - }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", diff --git a/package.json b/package.json index 52e1bd91..c5285dc3 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@cloudflare/speedtest": "^1.4.1", "@electron/remote": "^2.1.2", "@ionic/angular": "^6.0.3", - "@m-lab/ndt7": "^0.0.6", + "@m-lab/ndt7": "^0.1.5", "@ngx-translate/core": "^14.0.0", "@ngx-translate/http-loader": "^7.0.0", "@sentry/browser": "^5.5.0", diff --git a/src/app/services/measurement-client.service.spec.ts b/src/app/services/measurement-client.service.spec.ts index 1d26111d..6c713498 100644 --- a/src/app/services/measurement-client.service.spec.ts +++ b/src/app/services/measurement-client.service.spec.ts @@ -2,7 +2,9 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common import { TestBed } from '@angular/core/testing'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { Network } from '@awesome-cordova-plugins/network/ngx'; +import ndt7 from '@m-lab/ndt7'; import { MeasurementClientService } from './measurement-client.service'; +import { environment } from '../../environments/environment'; describe('MeasurementClientService', () => { let service: MeasurementClientService; @@ -23,3 +25,73 @@ describe('MeasurementClientService', () => { expect(service).toBeTruthy(); }); }); + +describe('MeasurementClientService ndt7 package integration', () => { + let service: MeasurementClientService; + let ndt7TestSpy: jasmine.Spy; + + beforeEach(() => { + ndt7TestSpy = spyOn(ndt7, 'test').and.resolveTo(0); + + const historyService: any = { add: jasmine.createSpy('add') }; + const settingsService: any = { + get: jasmine.createSpy('get').and.returnValue(false), + currentSettings: { uploadEnabled: false }, + }; + const networkService: any = { + getNetInfo: jasmine.createSpy('getNetInfo').and.resolveTo({}), + }; + const uploadService: any = { + uploadMeasurement: jasmine.createSpy('uploadMeasurement'), + }; + const sharedService: any = { + broadcast: jasmine.createSpy('broadcast'), + on: jasmine.createSpy('on'), + }; + + service = new MeasurementClientService( + historyService, + settingsService, + networkService, + uploadService, + sharedService + ); + spyOn(service, 'finalizeMeasurement').and.resolveTo(undefined); + }); + + it('runs the test through the npm package with the giga-meter metadata', async () => { + await service.runTest('manual'); + + expect(ndt7TestSpy).toHaveBeenCalledTimes(1); + const config = ndt7TestSpy.calls.mostRecent().args[0]; + expect(config.metadata).toEqual({ + client_name: 'giga-meter', + client_version: environment.app_version, + }); + expect(config.userAcceptedDataPolicy).toBeTrue(); + expect(config.downloadworkerfile).toBe( + 'assets/js/ndt/ndt7-download-worker.js' + ); + expect(config.uploadworkerfile).toBe('assets/js/ndt/ndt7-upload-worker.js'); + }); + + it('still classifies locate-server failures as retryable', async () => { + (service as any).maxRetries = 1; + ndt7TestSpy.and.rejectWith( + new Error('TypeError: Failed to fetch locate.measurementlab.net') + ); + + await service.runTest('manual'); + + // one initial attempt + one retry, then it gives up + expect(ndt7TestSpy).toHaveBeenCalledTimes(2); + }); + + it('does not retry non-locate test failures', async () => { + ndt7TestSpy.and.rejectWith(new Error('websocket closed unexpectedly')); + + await service.runTest('manual'); + + expect(ndt7TestSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/services/measurement-client.service.ts b/src/app/services/measurement-client.service.ts index 96829006..a57a0e2d 100644 --- a/src/app/services/measurement-client.service.ts +++ b/src/app/services/measurement-client.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; -import ndt7 from '../../assets/js/ndt/ndt7.js'; +import ndt7 from '@m-lab/ndt7'; +import { environment } from '../../environments/environment'; import { BehaviorSubject, Subject } from 'rxjs'; import { HistoryService } from './history.service'; import { SettingsService } from './settings.service'; @@ -25,8 +26,15 @@ export class MeasurementClientService { ).asObservable(); private readonly testConfig = { userAcceptedDataPolicy: true, + // Served from node_modules/@m-lab/ndt7 via the angular.json assets glob downloadworkerfile: 'assets/js/ndt/ndt7-download-worker.js', uploadworkerfile: 'assets/js/ndt/ndt7-upload-worker.js', + // Identifies our measurements in the M-Lab dataset (was hardcoded in the + // vendored copy of ndt7.js before) + metadata: { + client_name: 'giga-meter', + client_version: environment.app_version, + }, }; mlabInformation = { diff --git a/src/assets/js/ndt/ndt7-download-worker.js b/src/assets/js/ndt/ndt7-download-worker.js deleted file mode 100644 index ae02d39f..00000000 --- a/src/assets/js/ndt/ndt7-download-worker.js +++ /dev/null @@ -1,99 +0,0 @@ -/* eslint-env browser, node, worker */ - -// workerMain is the WebWorker function that runs the ndt7 download test. -const workerMain = function(ev) { - 'use strict'; - const url = ev.data['///ndt/v7/download']; - const sock = new WebSocket(url, 'net.measurementlab.ndt.v7'); - let now; - if (typeof performance !== 'undefined' && - typeof performance.now === 'function') { - now = () => performance.now(); - } else { - now = () => Date.now(); - } - downloadTest(sock, postMessage, now); -}; - -/** - * downloadTest is a function that runs an ndt7 download test using the - * passed-in websocket instance and the passed-in callback function. The - * socket and callback are passed in to enable testing and mocking. - * - * @param {WebSocket} sock - The WebSocket being read. - * @param {function} postMessage - A function for messages to the main thread. - * @param {function} now - A function returning a time in milliseconds. - */ -const downloadTest = function(sock, postMessage, now) { - sock.onclose = function() { - postMessage({ - MsgType: 'complete', - }); - }; - - sock.onerror = function(ev) { - postMessage({ - MsgType: 'error', - Error: ev.type, - }); - }; - - let start = now(); - let previous = start; - let total = 0; - - sock.onopen = function() { - start = now(); - previous = start; - total = 0; - postMessage({ - MsgType: 'start', - Data: { - ClientStartTime: start, - }, - }); - }; - - sock.onmessage = function(ev) { - total += - (typeof ev.data.size !== 'undefined') ? ev.data.size : ev.data.length; - // Perform a client-side measurement 4 times per second. - const t = now(); - const every = 250; // ms - if (t - previous > every) { - postMessage({ - MsgType: 'measurement', - ClientData: { - ElapsedTime: (t - start) / 1000, // seconds - NumBytes: total, - // MeanClientMbps is calculated via the logic: - // (bytes) * (bits / byte) * (megabits / bit) = Megabits - // (Megabits) * (1/milliseconds) * (milliseconds / second) = Mbps - // Collect the conversion constants, we find it is 8*1000/1000000 - // When we simplify we get: 8*1000/1000000 = .008 - MeanClientMbps: (total / (t - start)) * 0.008, - }, - Source: 'client', - }); - previous = t; - } - - // Pass along every server-side measurement. - if (typeof ev.data === 'string') { - postMessage({ - MsgType: 'measurement', - ServerMessage: ev.data, - Source: 'server', - }); - } - }; -}; - -// Node and browsers get onmessage defined differently. -if (typeof self !== 'undefined') { - self.onmessage = workerMain; -} else if (typeof this !== 'undefined') { - this.onmessage = workerMain; -} else if (typeof onmessage !== 'undefined') { - onmessage = workerMain; -} diff --git a/src/assets/js/ndt/ndt7-upload-worker.js b/src/assets/js/ndt/ndt7-upload-worker.js deleted file mode 100644 index a1d55e01..00000000 --- a/src/assets/js/ndt/ndt7-upload-worker.js +++ /dev/null @@ -1,168 +0,0 @@ -/* eslint-env es6, browser, node, worker */ - -// WebWorker that runs the ndt7 upload test -const workerMain = function(ev) { - const url = ev.data['///ndt/v7/upload']; - const sock = new WebSocket(url, 'net.measurementlab.ndt.v7'); - let now; - if (typeof performance !== 'undefined' && - typeof performance.now === 'function') { - now = () => performance.now(); - } else { - now = () => Date.now(); - } - uploadTest(sock, postMessage, now); -}; - -const uploadTest = function(sock, postMessage, now) { - let closed = false; - sock.onclose = function() { - if (!closed) { - closed = true; - postMessage({ - MsgType: 'complete', - }); - } - }; - - sock.onerror = function(ev) { - postMessage({ - MsgType: 'error', - Error: ev.type, - }); - }; - - sock.onmessage = function(ev) { - if (typeof ev.data !== 'undefined') { - postMessage({ - MsgType: 'measurement', - Source: 'server', - ServerMessage: ev.data, - }); - } - }; - - /** - * uploader is the main loop that uploads data in the web browser. It must - * carefully balance a bunch of factors: - * 1) message size determines measurement granularity on the client side, - * 2) the JS event loop can only fire off so many times per second, and - * 3) websocket buffer tracking seems inconsistent between browsers. - * - * Because of (1), we need to have small messages on slow connections, or - * else this will not accurately measure slow connections. Because of (2), if - * we use small messages on fast connections, then we will not fill the link. - * Because of (3), we can't depend on the websocket buffer to "fill up" in a - * reasonable amount of time. - * - * So on fast connections we need a big message size (one the message has - * been handed off to the browser, it runs on the browser's fast compiled - * internals) and on slow connections we need a small message. Because this - * is used as a speed test, we don't know before the test which strategy we - * will be using, because we don't know the speed before we test it. - * Therefore, we use a strategy where we grow the message exponentially over - * time. In an effort to be kind to the memory allocator, we always double - * the message size instead of growing it by e.g. 1.3x. - * - * @param {*} data - * @param {*} start - * @param {*} end - * @param {*} previous - * @param {*} total - */ - function uploader(data, start, end, previous, total) { - if (closed) { - // socket.send() with too much buffering causes socket.close(). We only - // observed this behaviour with pre-Chromium Edge. - return; - } - const t = now(); - if (t >= end) { - sock.close(); - // send one last measurement. - postClientMeasurement(total, sock.bufferedAmount, start); - return; - } - - const maxMessageSize = 8388608; /* = (1<<23) = 8MB */ - const clientMeasurementInterval = 250; // ms - - // Message size is doubled after the first 16 messages, and subsequently - // every 8, up to maxMessageSize. - const nextSizeIncrement = - (data.length >= maxMessageSize) ? Infinity : 16 * data.length; - if ((total - sock.bufferedAmount) >= nextSizeIncrement) { - data = new Uint8Array(data.length * 2); - } - - // We keep 7 messages in the send buffer, so there is always some more - // data to send. The maximum buffer size is 8 * 8MB - 1 byte ~= 64M. - const desiredBuffer = 7 * data.length; - if (sock.bufferedAmount < desiredBuffer) { - sock.send(data); - total += data.length; - } - - if (t >= previous + clientMeasurementInterval) { - postClientMeasurement(total, sock.bufferedAmount, start); - previous = t; - } - - // Loop the uploader function in a way that respects the JS event handler. - setTimeout(() => uploader(data, start, end, previous, total), 0); - } - - /** Report measurement back to the main thread. - * - * @param {*} total - * @param {*} bufferedAmount - * @param {*} start - */ - function postClientMeasurement(total, bufferedAmount, start) { - // bytes sent - bytes buffered = bytes actually sent - const numBytes = total - bufferedAmount; - // ms / 1000 = seconds - const elapsedTime = (now() - start) / 1000; - // bytes * bits/byte * megabits/bit * 1/seconds = Mbps - const meanMbps = numBytes * 8 / 1000000 / elapsedTime; - postMessage({ - MsgType: 'measurement', - ClientData: { - ElapsedTime: elapsedTime, - NumBytes: numBytes, - MeanClientMbps: meanMbps, - }, - Source: 'client', - Test: 'upload', - }); - } - - sock.onopen = function() { - const initialMessageSize = 8192; /* (1<<13) = 8kBytes */ - // TODO(bassosimone): fill this message - see above comment - const data = new Uint8Array(initialMessageSize); - const start = now(); // ms since epoch - const duration = 10000; // ms - const end = start + duration; // ms since epoch - - postMessage({ - MsgType: 'start', - Data: { - StartTime: start / 1000, // seconds since epoch - ExpectedEndTime: end / 1000, // seconds since epoch - }, - }); - - // Start the upload loop. - uploader(data, start, end, start, 0); - }; -}; - -// Node and browsers get onmessage defined differently. -if (typeof self !== 'undefined') { - self.onmessage = workerMain; -} else if (typeof this !== 'undefined') { - this.onmessage = workerMain; -} else if (typeof onmessage !== 'undefined') { - onmessage = workerMain; -} diff --git a/src/assets/js/ndt/ndt7.js b/src/assets/js/ndt/ndt7.js deleted file mode 100644 index 50aa50bf..00000000 --- a/src/assets/js/ndt/ndt7.js +++ /dev/null @@ -1,337 +0,0 @@ -/* eslint-env browser, node, worker */ - -// ndt7 contains the core ndt7 client functionality. Please, refer -// to the ndt7 spec available at the following URL: -// -// https://github.com/m-lab/ndt-server/blob/master/spec/ndt7-protocol.md -// -// This implementation uses v0.9.0 of the spec. - -// Wrap everything in a closure to ensure that local definitions don't -// permanently shadow global definitions. -(function () { - "use strict"; - - /** - * @name ndt7 - * @namespace ndt7 - */ - const ndt7 = (function () { - const staticMetadata = { - client_library_name: "ndt7-js", - client_library_version: "0.0.6", - client_name: "giga-meter", - }; - // cb creates a default-empty callback function, allowing library users to - // only need to specify callback functions for the events they care about. - // - // This function is not exported. - const cb = function (name, callbacks, defaultFn) { - if (typeof callbacks !== "undefined" && name in callbacks) { - return callbacks[name]; - } else if (typeof defaultFn !== "undefined") { - return defaultFn; - } else { - // If no default function is provided, use the empty function. - return function () {}; - } - }; - - // The default response to an error is to throw an exception. - const defaultErrCallback = function (err) { - throw new Error(err); - }; - - /** - * discoverServerURLs contacts a web service (likely the Measurement Lab - * locate service, but not necessarily) and gets URLs with access tokens in - * them for the client. It can be short-circuted if config.server exists, - * which is useful for clients served from the webserver of an NDT server. - * - * @param {Object} config - An associative array of configuration options. - * @param {Object} userCallbacks - An associative array of user callbacks. - * - * It uses the callback functions `error`, `serverDiscovery`, and - * `serverChosen`. - * - * @name ndt7.discoverServerURLS - * @public - */ - async function discoverServerURLs(config, userCallbacks) { - config.metadata = Object.assign({}, config.metadata); - config.metadata = Object.assign(config.metadata, staticMetadata); - const callbacks = { - error: cb("error", userCallbacks, defaultErrCallback), - serverDiscovery: cb("serverDiscovery", userCallbacks), - serverChosen: cb("serverChosen", userCallbacks), - }; - let protocol = "wss"; - if (config && "protocol" in config) { - protocol = config.protocol; - } - - const metadata = new URLSearchParams(config.metadata); - // If a server was specified, use it. - if (config && "server" in config) { - // Add metadata as querystring parameters. - const downloadURL = new URL( - protocol + "://" + config.server + "/ndt/v7/download" - ); - const uploadURL = new URL( - protocol + "://" + config.server + "/ndt/v7/upload" - ); - downloadURL.search = metadata; - uploadURL.search = metadata; - return { - "///ndt/v7/download": downloadURL.toString(), - "///ndt/v7/upload": uploadURL.toString(), - }; - } - - // If no server was specified then use a loadbalancer. If no loadbalancer - // is specified, use the locate service from Measurement Lab. - const lbURL = - config && "loadbalancer" in config - ? new URL(config.loadbalancer) - : new URL("https://locate.measurementlab.net/v2/nearest/ndt/ndt7"); - lbURL.search = metadata; - callbacks.serverDiscovery({ loadbalancer: lbURL }); - const response = await fetch(lbURL).catch((err) => { - throw new Error(err); - }); - const js = await response.json(); - if (!("results" in js)) { - callbacks.error(`Could not understand response from ${lbURL}: ${js}`); - return {}; - } - - // TODO: do not discard unused results. If the first server is unavailable - // the client should quickly try the next server. - // - // Choose the first result sent by the load balancer. This ensures that - // in cases where we have a single pod in a metro, that pod is used to - // run the measurement. When there are multiple pods in the same metro, - // they are randomized by the load balancer already. - const choice = js.results[0]; - callbacks.serverChosen(choice); - - return { - "///ndt/v7/download": choice.urls[protocol + ":///ndt/v7/download"], - "///ndt/v7/upload": choice.urls[protocol + ":///ndt/v7/upload"], - }; - } - - /* - * runNDT7Worker is a helper function that runs a webworker. It uses the - * callback functions `error`, `start`, `measurement`, and `complete`. It - * returns a c-style return code. 0 is success, non-zero is some kind of - * failure. - * - * @private - */ - const runNDT7Worker = async function ( - config, - callbacks, - urlPromise, - filename, - testType - ) { - if ( - config.userAcceptedDataPolicy !== true && - config.mlabDataPolicyInapplicable !== true - ) { - callbacks.error( - "The M-Lab data policy is applicable and the user " + - "has not explicitly accepted that data policy." - ); - return 1; - } - - let clientMeasurement; - let serverMeasurement; - - // This makes the worker. The worker won't actually start until it - // receives a message. - const worker = new Worker(filename); - - // When the workerPromise gets resolved it will terminate the worker. - // Workers are resolved with c-style return codes. 0 for success, - // non-zero for failure. - const workerPromise = new Promise((resolve) => { - worker.resolve = function (returnCode) { - if (returnCode == 0) { - callbacks.complete({ - LastClientMeasurement: clientMeasurement, - LastServerMeasurement: serverMeasurement, - }); - } - worker.terminate(); - resolve(returnCode); - }; - }); - - // If the worker takes 12 seconds, kill it and return an error code. - // Most clients take longer than 10 seconds to complete the upload and - // finish sending the buffer's content, sometimes hitting the socket's - // timeout of 15 seconds. This makes sure uploads terminate on time and - // get a chance to send one last measurement after 10s. - const workerTimeout = setTimeout(() => worker.resolve(0), 12000); - - // This is how the worker communicates back to the main thread of - // execution. The MsgTpe of `ev` determines which callback the message - // gets forwarded to. - worker.onmessage = function (ev) { - if (!ev.data || !ev.data.MsgType || ev.data.MsgType === "error") { - clearTimeout(workerTimeout); - worker.resolve(1); - const msg = !ev.data ? `${testType} error` : ev.data.Error; - callbacks.error(msg); - } else if (ev.data.MsgType === "start") { - callbacks.start(ev.data.Data); - } else if (ev.data.MsgType == "measurement") { - // For performance reasons, we parse the JSON outside of the thread - // doing the downloading or uploading. - if (ev.data.Source == "server") { - serverMeasurement = JSON.parse(ev.data.ServerMessage); - callbacks.measurement({ - Source: ev.data.Source, - Data: serverMeasurement, - }); - } else { - clientMeasurement = ev.data.ClientData; - callbacks.measurement({ - Source: ev.data.Source, - Data: ev.data.ClientData, - }); - } - } else if (ev.data.MsgType == "complete") { - clearTimeout(workerTimeout); - worker.resolve(0); - } - }; - - // We can't start the worker until we know the right server, so we wait - // here to find that out. - const urls = await urlPromise.catch((err) => { - // Clear timer, terminate the worker and rethrow the error. - clearTimeout(workerTimeout); - worker.resolve(2); - throw err; - }); - - // Start the worker. - worker.postMessage(urls); - - // Await the resolution of the workerPromise. - return await workerPromise; - - // Liveness guarantee - once the promise is resolved, .terminate() has - // been called and the webworker will be terminated or in the process of - // being terminated. - }; - - /** - * downloadTest runs just the NDT7 download test. - * @param {Object} config - An associative array of configuration strings - * @param {Object} userCallbacks - * @param {Object} urlPromise - A promise that will resolve to urls. - * - * @return {number} Zero on success, and non-zero error code on failure. - * - * @name ndt7.downloadTest - * @public - */ - async function downloadTest(config, userCallbacks, urlPromise) { - const callbacks = { - error: cb("error", userCallbacks, defaultErrCallback), - start: cb("downloadStart", userCallbacks), - measurement: cb("downloadMeasurement", userCallbacks), - complete: cb("downloadComplete", userCallbacks), - }; - const workerfile = config.downloadworkerfile || "ndt7-download-worker.js"; - return await runNDT7Worker( - config, - callbacks, - urlPromise, - workerfile, - "download" - ).catch((err) => { - callbacks.error(err); - }); - } - - /** - * uploadTest runs just the NDT7 download test. - * @param {Object} config - An associative array of configuration strings - * @param {Object} userCallbacks - * @param {Object} urlPromise - A promise that will resolve to urls. - * - * @return {number} Zero on success, and non-zero error code on failure. - * - * @name ndt7.uploadTest - * @public - */ - async function uploadTest(config, userCallbacks, urlPromise) { - const callbacks = { - error: cb("error", userCallbacks, defaultErrCallback), - start: cb("uploadStart", userCallbacks), - measurement: cb("uploadMeasurement", userCallbacks), - complete: cb("uploadComplete", userCallbacks), - }; - const workerfile = config.uploadworkerfile || "ndt7-upload-worker.js"; - const rv = await runNDT7Worker( - config, - callbacks, - urlPromise, - workerfile, - "upload" - ).catch((err) => { - callbacks.error(err); - }); - return rv << 4; - } - - /** - * test discovers a server to run against and then runs a download test - * followed by an upload test. - * - * @param {Object} config - An associative array of configuration strings - * @param {Object} userCallbacks - * - * @return {number} Zero on success, and non-zero error code on failure. - * - * @name ndt7.test - * @public - */ - async function test(config, userCallbacks) { - // Starts the asynchronous process of server discovery, allowing other - // stuff to proceed in the background. - const urlPromise = discoverServerURLs(config, userCallbacks); - const downloadSuccess = await downloadTest( - config, - userCallbacks, - urlPromise - ); - const uploadSuccess = await uploadTest(config, userCallbacks, urlPromise); - return downloadSuccess + uploadSuccess; - } - - return { - discoverServerURLs: discoverServerURLs, - downloadTest: downloadTest, - uploadTest: uploadTest, - test: test, - }; - })(); - - // Modules are used by `require`, if this file is included on a web page, then - // module will be undefined and we use the window.ndt7 piece. - if (typeof module !== "undefined" && typeof module.exports !== "undefined") { - module.exports = ndt7; - } else { - window.ndt7 = ndt7; - } -})(); - -// Export the ndt7 object as a default export -export default ndt7; diff --git a/src/types/ndt7.d.ts b/src/types/ndt7.d.ts new file mode 100644 index 00000000..af3fbb1e --- /dev/null +++ b/src/types/ndt7.d.ts @@ -0,0 +1,44 @@ +declare module '@m-lab/ndt7' { + export interface Ndt7Config { + userAcceptedDataPolicy?: boolean; + mlabDataPolicyInapplicable?: boolean; + downloadworkerfile?: string; + uploadworkerfile?: string; + server?: string; + protocol?: string; + loadbalancer?: string; + clientRegistrationToken?: string; + metadata?: Record; + } + + export interface Ndt7Callbacks { + error?: (err: any) => void; + serverDiscovery?: (data: { loadbalancer: URL }) => void; + serverChosen?: (server: any) => void; + downloadStart?: (data: any) => void; + downloadMeasurement?: (data: any) => void; + downloadComplete?: (data: any) => void; + uploadStart?: (data: any) => void; + uploadMeasurement?: (data: any) => void; + uploadComplete?: (data: any) => void; + } + + const ndt7: { + discoverServerURLs: ( + config: Ndt7Config, + userCallbacks: Ndt7Callbacks + ) => Promise; + downloadTest: ( + config: Ndt7Config, + userCallbacks: Ndt7Callbacks, + urlPromise: Promise + ) => Promise; + uploadTest: ( + config: Ndt7Config, + userCallbacks: Ndt7Callbacks, + urlPromise: Promise + ) => Promise; + test: (config: Ndt7Config, userCallbacks: Ndt7Callbacks) => Promise; + }; + export default ndt7; +} From dd579dc0c4357c6e1d4a529747ff6361c354794c Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Fri, 7 Aug 2026 16:30:45 +0200 Subject: [PATCH 06/22] fix: repair spec imports broken by earlier renames indexed-db.service.spec.ts imported LocalStorageService from a file that no longer exists; invalidlocation.page.spec.ts imported SchoolnotfoundPage from invalidlocation.page, which exports InvalidLocationPage. Both broke compilation of the whole karma suite. Co-Authored-By: Claude Fable 5 --- src/app/invalidlocation/invalidlocation.page.spec.ts | 12 ++++++------ src/app/services/indexed-db.service.spec.ts | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/app/invalidlocation/invalidlocation.page.spec.ts b/src/app/invalidlocation/invalidlocation.page.spec.ts index 09639600..f4318d32 100644 --- a/src/app/invalidlocation/invalidlocation.page.spec.ts +++ b/src/app/invalidlocation/invalidlocation.page.spec.ts @@ -2,16 +2,16 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { IonicModule } from '@ionic/angular'; import { RouterTestingModule } from "@angular/router/testing"; import { TranslateModule } from '@ngx-translate/core'; -import { SchoolnotfoundPage } from './invalidlocation.page'; +import { InvalidLocationPage } from './invalidlocation.page'; import { ActivatedRoute } from "@angular/router"; -describe('SchoolnotfoundPage', () => { - let component: SchoolnotfoundPage; - let fixture: ComponentFixture; +describe('InvalidLocationPage', () => { + let component: InvalidLocationPage; + let fixture: ComponentFixture; let activatedroute: ActivatedRoute; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [ SchoolnotfoundPage ], + declarations: [ InvalidLocationPage ], imports: [ IonicModule.forRoot(), RouterTestingModule, @@ -19,7 +19,7 @@ describe('SchoolnotfoundPage', () => { ] }).compileComponents(); - fixture = TestBed.createComponent(SchoolnotfoundPage); + fixture = TestBed.createComponent(InvalidLocationPage); activatedroute = TestBed.inject(ActivatedRoute); component = fixture.componentInstance; fixture.detectChanges(); diff --git a/src/app/services/indexed-db.service.spec.ts b/src/app/services/indexed-db.service.spec.ts index ba1dbd43..ac73d02b 100644 --- a/src/app/services/indexed-db.service.spec.ts +++ b/src/app/services/indexed-db.service.spec.ts @@ -1,13 +1,13 @@ import { TestBed } from '@angular/core/testing'; -import { LocalStorageService } from './local-storage.service'; +import { IndexedDBService } from './indexed-db.service'; -describe('LocalStorageService', () => { - let service: LocalStorageService; +describe('IndexedDBService', () => { + let service: IndexedDBService; beforeEach(() => { TestBed.configureTestingModule({}); - service = TestBed.inject(LocalStorageService); + service = TestBed.inject(IndexedDBService); }); it('should be created', () => { From c9b179604866cfbd053752339b27cffa9cc000a8 Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Mon, 10 Aug 2026 13:10:16 +0200 Subject: [PATCH 07/22] chore: allow @m-lab/ndt7 as a CommonJS dependency Silences the optimization-bailout warning the same way electron already is. Co-Authored-By: Claude Fable 5 --- angular.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/angular.json b/angular.json index 2cf60c21..acfb3128 100644 --- a/angular.json +++ b/angular.json @@ -50,7 +50,8 @@ "namedChunks": true, "allowedCommonJsDependencies": [ "electron", - "@electron/remote" + "@electron/remote", + "@m-lab/ndt7" ] }, "configurations": { From f405083a020867e0461cf94dc68176ee88f10149 Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Mon, 10 Aug 2026 13:32:40 +0200 Subject: [PATCH 08/22] i18n: update Uzbek translations from the reviewed translation sheet Applies 35 revised strings from 'Translations GigaMeter_UZB and Rus.xlsx' (186 rows keyed by dotted path). Most are terminology and typo fixes: 'ID' -> 'IDsi' agreement, 'ulanish' -> 'internet' where the English says connectivity, 'Maktabinggiz' -> 'Maktabingiz', and strings that were left in English ('ISP', 'Open Database License', 'Dashboard') now translated. releaseNotes.2.0.2.title is deliberately NOT applied: the sheet holds the old translation concatenated with a reworded one ('... tajribasi - Yaxshilangan ...'), which would render as a doubled title. Left at its current value pending confirmation from the translator. Verified: all 186 sheet keys resolve against the JSON (including the dotted version keys under releaseNotes and the array items), key set unchanged, and every {{placeholder}} and HTML tag still matches en.json. Co-Authored-By: Claude Fable 5 --- src/assets/i18n/uz.json | 70 ++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/src/assets/i18n/uz.json b/src/assets/i18n/uz.json index 3c81cc77..70c5bd95 100644 --- a/src/assets/i18n/uz.json +++ b/src/assets/i18n/uz.json @@ -4,23 +4,23 @@ "about": "{{appName}} haqida", "about-help": "Haqida", "about-project-connect": "{{appName}} HAQIDA", - "device-id": "QURILMA ID {{ device_id }} (Nusxalash uchun bosing)", - "device-id-copied": "Qurilma ID muvaffaqiyatli nusxalandi!", - "device-id-copy-failed": "Qurilma ID nusxalanmadi. Iltimos, qayta urinib ko‘ring.", + "device-id": "QURILMA IDsi {{ device_id }} (Nusxa olish uchun bosing)", + "device-id-copied": "Qurilma IDsi muvaffaqiyatli nusxalandi!", + "device-id-copy-failed": "Qurilma IDsi nusxalanmadi. Iltimos, qayta urinib ko‘ring.", "faq": "Tez-tez so'raladigan savollar", "faq-help": "Savollar", "language-pref": "Til sozlamalari", - "my-school-id": "#Mening maktab ID raqamim", - "project-connect-desc": "{{appName}} kun davomida avtomatik ravishda internet tezligi va sifatini o‘lchab boradi, bu esa maktablarning vaqt o‘tishi bilan ulanish holatini aniq tasvirlaydi. Natijalar real vaqt rejimida maktablarning ulanish holatini ko‘rsatadigan global ochiq xarita — Giga Maps bilan avtomatik sinxronlanadi. Maktablarda kuchli internet ulanishi Gigaʼning barcha yoshlarni axborot, imkoniyat va tanlovga ulan tirish missiyasini qo‘llab-quvvatlaydi.", - "school-id": "MAKTAB ID RAQAMINGIZ", + "my-school-id": "#Maktabimning ID raqami", + "project-connect-desc": "{{appName}} kun davomida avtomatik ravishda internet tezligi va sifatini o‘lchab boradi, bu esa maktablarning vaqt o‘tishi bilan internet holatini aniq tasvirlaydi. Natijalar real vaqt rejimida maktablarning internet holatini ko‘rsatadigan global ochiq xarita — Giga Maps bilan avtomatik sinxronlanadi. Maktablarni sifatli internet ulanishi bilan ta'minlash Giga’ning barcha yoshlarni axborot, imkoniyat va tanlov bilan bog‘lash missiyasini qo‘llab-quvvatlaydi.", + "school-id": "MAKTABINGIZ ID RAQAMI", "test-type-setting": "Test turi sozlamalari", - "thank-you": "{{appName}} loyihasiga hissa qo‘shganingiz uchun rahmat. Maktabinggizni toping va ko‘proq ma’lumotni veb-saytimizdan bilib oling. Biz har kuni yangi maktablar qo‘shamiz!", + "thank-you": "{{appName}} loyihasiga hissa qo‘shganingiz uchun rahmat. Maktabingizni toping va ko‘proq ma’lumotni veb-saytimizdan bilib oling. Biz har kuni yangi maktablar qo‘shamiz!", "version": "VERSIYA {{ app_version }}", "view-project-connect": "{{appName}} NI KO‘RISH", - "whats-new": "Yangi nima?", + "whats-new": "Nima yangilik!", "wifi-network": "WiFi tarmoq sozlamalari" }, - "checkboxText": "GigaMeter’dan ro‘yxatdan o‘tib foydalanish orqali men Giga tomonidan o‘lchov va boshqa yordamchi ma’lumotlarimni yig‘ish, qayta ishlash va e’lon qilishga rozilik bildiraman", + "checkboxText": "GigaMeterdan ro‘yxatdan o‘tib foydalanish orqali men Giga tomonidan o‘lchov va boshqa yordamchi ma’lumotlarimni yig‘ish, qayta ishlash va e’lon qilishga rozilik bildiraman", "confirmSchool": { "confirm1": "Maktabingizni ro‘yxatdan o‘tkazish uchun ma’lumotlarni tasdiqlang", "confirm2": "YUQORIDAGI MA’LUMOT TO‘G‘RIMI?", @@ -28,16 +28,16 @@ }, "faq": { "FAQ": "TEZ-TEZ SO‘RALADIGAN SAVOLLAR", - "can-change-id": "Maktab ID raqamimni o‘zgartira olamanmi?", + "can-change-id": "Maktabimning ID raqamini o‘zgartira olamanmi?", "can-change-id-desc": "Hozirda maktab ID raqamini o‘zgartirish imkoni yo‘q. Agar xato kiritsangiz, ilovani o‘chirib, qaytadan o‘rnating.", "can-close-app": "Ilovani yopishim mumkinmi?", - "can-close-app-desc": "Ha, yuqori o‘ng burchakdagi yopish tugmasi orqali ilovani yopishingiz mumkin. Ilova fon rejimida ishlashda davom etadi va ulanish holatini yuboradi.", + "can-close-app-desc": "Ha, yuqori o‘ng burchakdagi yopish tugmasi orqali ilovani yopishingiz mumkin. Ilova fon rejimida ishlashda davom etadi va internet holatini yuboradi.", "connectivity-checks-occur": "Maktabimning
internet tekshiruvlari qachon o‘tkaziladi?", - "connectivity-checks-occur-desc": "Giga Meter kuniga 4 tagacha tezlik testi, har 15 daqiqada esa ping testi o‘tkazadi. Testlar mahalliy vaqt bilan 8:00 dan 20:00 gacha davom etadi. Agar qurilma o‘chirilgan yoki oflayn bo‘lsa, testlar o‘tmaydi. Istalgan vaqtda qo‘lda test o‘tkazishingiz mumkin.", + "connectivity-checks-occur-desc": "Giga Meter kuniga 4 tagacha tezlikni o'lchab boradi, har 15 daqiqada esa ping testi o‘tkazadi. O'lchovlar mahalliy vaqt bilan 8:00 dan 20:00 gacha davom etadi. Agar qurilma o‘chirilgan yoki oflayn bo‘lsa, o'lchamaydi. Istalgan vaqtda qo‘lda test o‘tkazishingiz mumkin.", "how-find-school-id": "Maktab ID raqamini qayerdan topsam bo‘ladi?", "how-find-school-id-desc": "Maktab ma’muriyati yoki IT bo‘limidan so‘rang. Maktab ID raqami milliy ro‘yxatdan o‘tish tizimida foydalaniladigan raqamdir.", "how-improve-access": "Giga Meter internetga kirishni qanday yaxshilaydi?", - "how-improve-access-desc": "Giga Meter internetni to‘g‘rilamaydi, lekin muammolarni aniq ko‘rsatadi. Ushbu ma’lumot hukumat va provayderlarga qaysi joylarda yaxshilash zarurligini belgilashga yordam beradi.", + "how-improve-access-desc": "Giga Meter internetni to‘g‘rilamaydi, lekin muammolarni aniq ko‘rsatadi. Ushbu ma’lumot hukumat va internet provayderlarga qaysi joylarda yaxshilash zarurligini belgilashga yordam beradi.", "multiple-installs": "Ilovani bir nechta kompyuterga
o‘rnatsam bo‘ladimi?", "multiple-installs-desc": "Ha, mumkin. Hatto tavsiya qilinadi. Ko‘proq qurilma ma’lumot yuborsa, o‘lchov aniqligi oshadi.", "school-id-not-found": "Maktabim hali {{appName}} tizimiga qo‘shilmagan. Nima qilishim mumkin?", @@ -46,10 +46,10 @@ "what-data-access": "Giga Meter qaysi ma’lumotlarga kirish oladi?", "what-data-access-desc": "Giga Meter faqat internet sifatini o‘lchash uchun zarur bo‘lgan tizim va tarmoq ma’lumotlariga kiradi — tezlik, mavjudlik, tarmoq tafsilotlari, qurilma turi va hokazo. U shaxsiy fayllaringizga, tarixingizga yoki kontentingizga kira olmaydi.", "what-is-school-id": "Maktab ID raqami nima?", - "what-is-school-id-desc": "Maktab ID — hukumat tomonidan berilgan noyob identifikator. Turli formatlar mavjud, odatda \"BR12345\" yoki \"12345678\" ko‘rinishda bo‘ladi.", + "what-is-school-id-desc": "Maktab ID — hukumat tomonidan berilgan noyob identifikator. Turli formatlar mavjud, odatda \"BR12345\" yoki \"12345678\" kabi ko‘rinishda bo‘lishi mumkin.", "where-see-results": "O‘tgan test natijalarini qayerdan ko‘ra olaman?", - "where-see-results-desc-part1": "\"Data\" sahifasida so‘nggi 10 muvaffaqiyatli testni ko‘rishingiz mumkin. Har bir kunning natijalari o‘rtacha qilib Giga Maps’ga joylanadi. Tashrif buyuring ", - "where-see-results-desc-part2": " maktabingizni toping va uning global natijalarini solishtiring.", + "where-see-results-desc-part1": "\"Ma'lumotlar\" sahifasida so‘nggi 10 muvaffaqiyatli testni ko‘rishingiz mumkin. Har bir kunning natijalari o‘rtacha qilib Giga Maps xaritasiga joylanadi. Tashrif buyuring", + "where-see-results-desc-part2": "maktabingizni toping va uning global natijalar bilan solishtiring.", "where-see-results-link": "maps.giga.global", "who-has-access": "O‘lchov ma’lumotlariga kimlar kira oladi?", "who-has-access-desc-part1": "Giga Meter tomonidan yig‘ilgan ayrim ma’lumotlar — yuklab olish tezligi, yuklash tezligi, kechikish, ishlash vaqti va noyob maktab identifikatori — Giga’ning ochiq maktab ulanishlar bazasida eʼlon qilinadi. Bu maʼlumotlar quyidagi litsenziya asosida beriladi:", @@ -61,13 +61,13 @@ "checkboxText": "GigaMeter’dan ro‘yxatdan o‘tib foydalanish orqali men Giga tomonidan o‘lchov va boshqa yordamchi ma’lumotlarimni yig‘ish, qayta ishlash va e’lon qilishga rozilik bildiraman", "learn-more": "{{appName}} HAQIDA KO‘PROQ BILISH", "learnMore": "Giga Meter haqida ko‘proq ma’lumot", - "licenseLink": "Open Database License (ODBL)", + "licenseLink": "Ochiq ma’lumotlar bazasi litsenziyasi (ODBL)", "next": "Keyingi", "privacy": "", "privacyPolicy": "Maxfiylik siyosati", "start": "Boshlash", "title1": "{{appName}} ga xush kelibsiz {{appNameSuffix}}", - "title2": "Maktabinggizning internet sifatini kuzating. Kunlik o‘lchovlar ulanishni yaxshilashga yordam beradi.", + "title2": "Maktabingizning internet sifatini kuzating. Kunlik o‘lchovlar internet ulanishini yaxshilashga yordam beradi.", "welcome": "Xush kelibsiz" }, "invalidLocation": { @@ -77,7 +77,7 @@ "language": "Til", "learn-more": "{{appName}} HAQIDA KO‘PROQ BILISH", "learnMore": "Giga Meter haqida ko‘proq bilish", - "licenseLink": "Open Database License (ODBL)", + "licenseLink": "Ochiq Ma’lumotlar Bazasi Litsenziyasi (ODBL)", "next": "Keyingi", "no": "Yo‘q", "notifications": { @@ -87,22 +87,22 @@ "privacy": "", "privacyPolicy": "Maxfiylik siyosati", "registerSchool": { - "register-part-2": "Har bir maktab ko‘pi bilan ikki qurilma o‘rnatishi mumkin, agar boshqacha ko‘rsatma berilmagan bo‘lsa.", + "register-part-2": "Har bir maktab ko‘pi bilan ikkita kompyuterda o‘rnatishi mumkin, agar boshqacha ko‘rsatma berilmagan bo‘lsa.", "register-step-1": "Maktabingizni ro‘yxatdan o‘tkazish arafasidasiz.", "register-step-2": "Giga Meter’ni faqat maktab internetiga ulanadigan va tez-tez ishlatiladigan kompyuterlarga o‘rnating.", - "register-step-3": "Imkon bo‘lsa, kamida bitta kompyuter simli (Ethernet) ulanish orqali internetga ulangan bo‘lsin.", + "register-step-3": "Imkon bo‘lsa, kamida bitta kompyuter simli (Ethernet) internetga ulangan bo‘lsin.", "start-registeration": "Ro‘yxatdan o‘tishni boshlash" }, "registerSchoolPage": { "privacy": "Giga Meter’dan foydalanib, men o‘lchov ma’lumotlarimni Giga tomonidan yig‘ish, qayta ishlash va e’lon qilishga rozilik bildiraman", - "readMore": "Yaxshi ro‘yxatdan o‘tish uchun ko‘rsatmalar", - "tip1": "Afsuski, simli ulanish afzalroq", + "readMore": "Muvaffaqiyatli ro‘yxatdan o‘tish haqida ko‘proq o‘qing", + "tip1": "Afsuski, simli ulanish (internet) afzalroq", "tip2": "Ko‘proq ishlatiladigan ikki qurilmaga o‘rnating", "tip3": "Faqat maktab internetiga ulangan bo‘lishi kerak", "title1": "Maktabingizni ro‘yxatdan o‘tkazmoqchisiz", - "title2": "Yaxshi ro‘yxatdan o‘tish uchun kompyuterlar quyidagilarga mos bo‘lishi kerak:" + "title2": "Yaxshi ro‘yxatdan o‘tish jarayoni uchun kompyuterlar quyidagicha bo‘lishiga ishonch hosil qiling:" }, - "resultSchool": "MAKTAB ID UCHUN NATIJALAR", + "resultSchool": "MAKTAB IDsi UCHUN NATIJALAR", "saveEmail": { "emailFormat": "Iltimos, to‘g‘ri formatdagi emailni kiriting (masalan, info@school.com).", "emailRequired": "Email talab qilinadi.", @@ -112,7 +112,7 @@ }, "schoolDetails": { "add": "Qo‘shish", - "checkGiga": "Giga Maps’da tekshirish", + "checkGiga": "Giga Maps xaritasida tekshirish", "confirmText": ""Ha" tugmasini bosganingizdan so‘ng, maktabni o‘zgartirish uchun ilovani o‘chirib qayta o‘rnatishingiz kerak bo‘ladi.", "enterEmail": "Matn kiriting", "noSchoolList": "MAKTABIM RO‘YXATDA YO‘Q", @@ -135,9 +135,9 @@ "app-run-desc": "Maktab ma’lumotlari qurilma yoqilganida yig‘iladi.", "congratulations": "Tabriklaymiz — hammasi tayyor!", "get-started": "Boshlash", - "go-dashboard": "Dashboard’ga o‘tish", + "go-dashboard": "Panel boshqaruviga o‘tish", "internet-checks": "Ilova kuniga 4 marta
internet holatini tekshiradi.", - "school-registered": "Maktabinggiz {{appName}} ga
ro‘yxatdan o‘tkazildi", + "school-registered": "Maktabingiz {{appName}} da
ro‘yxatdan o‘tkazildi", "success-txt": "Kunlik internet
tekshiruvi yo‘lga qo‘yildi!", "you-can-close": "Ilovani yopishingiz mumkin." }, @@ -146,7 +146,7 @@ "check": "Mamlakatingiz aniqlanmoqda", "confirm": "Tasdiqlash", "country": "Mamlakat", - "country-not-accurate": "Tanlangan mamlakat joylashuvingizga mos kelmayotgandek.", + "country-not-accurate": "Siz tanlangan mamlakat joylashuvingizga mos kelmayotgandek.", "detected-auto": "Mamlakatingiz avtomatik aniqlangan", "loading": "Yuklanmoqda", "not-available": "{{appName}} {{appNameSuffix}} ushbu mamlakatda mavjud emas. Iltimos, boshqa mamlakat tanlang.", @@ -169,7 +169,7 @@ "download": "YUKLAB OLISH", "downloadLowerCase": "Yuklab olish", "home": "Bosh sahifa", - "isp": "ISP", + "isp": "Internet provayderi", "latency": "Kechikish: {{latency}}", "latencyDetail": "Kechikish", "locateServerError": "Test serverlarini topib bo‘lmadi. Iltimos, internet ulanishini tekshiring.", @@ -201,7 +201,7 @@ "whatsNew": { "checkReleaseNotes": "Reliz yozuvlarini ko‘rish", "congratulations": "Tabriklaymiz!", - "goToDashboard": "Dashboard’ga o‘tish", + "goToDashboard": "Boshqaruv paneliga o‘tish", "headline": "Sarlavha" }, "releaseNotes": { @@ -217,17 +217,17 @@ "title": "Ping bilan uptime kuzatuvi va yaxshilangan foydalanuvchi tajribasi", "date": "Dushanba, 2025-yil 10-noyabr", "items": [ - "Yangi Ping funksiyasi orqali uptime kuzatish", + "Yangi Ping funksiyasi bilan ishlash vaqtini kuzating", "Tezlik testlari tarixini ko‘rish", - "Yaxshilangan foydalanuvchi tajribasi" + "Yaxshilangan foydalanuvchi tajribasidan bahramand bo‘ling" ] } }, "logout": { "title": "Chiqishni tasdiqlang", "warning": "Bu amal sizni va ushbu qurilmadagi boshqa foydalanuvchilarni ilovadan chiqaradi, maktabni ro‘yxatdan o‘chirishni amalga oshiradi va barcha mahalliy ma’lumotlarni tozalaydi.", - "confirmText": "Maktabni ro‘yxatdan o‘chirishni tasdiqlash uchun quyidagi ID’ni kiriting: {{ schoolId }}.", - "placeholder": "Maktab ID’ni kiriting", + "confirmText": "Maktabni ro‘yxatdan o‘chirishni tasdiqlash uchun quyidagi IDni kiriting: {{ schoolId }}.", + "placeholder": "Maktab IDsini kiriting", "errorMessage": "Maktab ID mos kelmadi. Qayta urinib ko‘ring.", "logoutButton": "Chiqish", "cancelButton": "Bekor qilish" From d1666d76a7e22cb642e5098a76849faadd162c67 Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Tue, 11 Aug 2026 11:50:07 +0200 Subject: [PATCH 09/22] feat(research): add system/network info probe for plan 0008 Standalone Node probe (no build, no Electron) covering the release v2.0.4 item-3 research: network interfaces, gateway, DNS, VPN inference, Wi-Fi, network stats/connections, OS, CPU, disk, memory and elevation. Two passes to flag volatile attributes, per-call timing, and JSON (raw + redacted) + CSV outputs. Probe outputs are gitignored: the raw dump contains SSIDs, MACs, internal IPs and the Windows username. Co-Authored-By: Claude Fable 5 --- scripts/research/.gitignore | 5 + scripts/research/probe-system-info.js | 552 ++++++++++++++++++++++++++ 2 files changed, 557 insertions(+) create mode 100644 scripts/research/.gitignore create mode 100644 scripts/research/probe-system-info.js diff --git a/scripts/research/.gitignore b/scripts/research/.gitignore new file mode 100644 index 00000000..956374a0 --- /dev/null +++ b/scripts/research/.gitignore @@ -0,0 +1,5 @@ +# Probe outputs: the raw JSON contains SSIDs, MACs, internal IPs and the +# Windows username. Never commit them; share only the -redacted.json / CSV +# after review. +probe-*.json +probe-*.csv diff --git a/scripts/research/probe-system-info.js b/scripts/research/probe-system-info.js new file mode 100644 index 00000000..2c31a33a --- /dev/null +++ b/scripts/research/probe-system-info.js @@ -0,0 +1,552 @@ +#!/usr/bin/env node +/** + * probe-system-info.js — Research probe for Plan 0008 (network & device info). + * + * Standalone Node script: no app build, no Electron. Copy this file to the + * target Windows PC and run: + * + * node probe-system-info.js + * + * It resolves `systeminformation` from the repo's node_modules when run in + * place; on a standalone copy run `npm i systeminformation@^5` next to it. + * + * For every attribute in the ticket list it: + * 1. runs the systeminformation call (or the native fallback), + * 2. measures duration in ms, + * 3. records the value, whether it came back empty, and any error, + * 4. runs a second pass a few seconds later to flag volatile values. + * + * Outputs (written to the current working directory): + * probe--.json raw dump — DO NOT share as-is + * probe---redacted.json masked copy, safe to attach + * probe--.csv one row per attribute (redacted) + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFile, spawnSync } = require('child_process'); + +// --------------------------------------------------------------------------- +// Load systeminformation from the repo or from a local npm install. +// --------------------------------------------------------------------------- +function loadSysteminformation() { + const candidates = [ + 'systeminformation', + path.join(__dirname, '..', '..', 'node_modules', 'systeminformation'), + path.join(__dirname, '..', '..', 'electron', 'node_modules', 'systeminformation'), + path.join(process.cwd(), 'node_modules', 'systeminformation'), + ]; + for (const candidate of candidates) { + try { + return require(candidate); + } catch (_) { + /* try next */ + } + } + console.error( + 'No pude cargar "systeminformation". Corre el script desde el repo o haz\n' + + '`npm i systeminformation@^5` en la carpeta donde copiaste este archivo.' + ); + process.exit(1); +} + +const si = loadSysteminformation(); + +const PASS_DELAY_MS = 5000; // gap between pass 1 and pass 2 (volatility check) +const NETSTATS_SAMPLE_GAP_MS = 2000; // gap between the two networkStats samples + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function runPowershellJson(psCommand) { + return new Promise((resolve, reject) => { + execFile( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', psCommand + ' | ConvertTo-Json -Depth 4'], + { timeout: 30000, windowsHide: true, maxBuffer: 10 * 1024 * 1024 }, + (err, stdout) => { + if (err) return reject(err); + const text = stdout.trim(); + if (!text) return resolve(null); + try { + resolve(JSON.parse(text)); + } catch (parseErr) { + reject(new Error('PowerShell devolvió algo no-JSON: ' + text.slice(0, 200))); + } + } + ); + }); +} + +/** True when the current process is elevated (fltmc requires admin). */ +function isProcessElevated() { + const result = spawnSync('fltmc.exe', [], { windowsHide: true, stdio: 'ignore' }); + return result.status === 0; +} + +/** VPN heuristic: virtual adapters + well-known VPN driver/interface names. */ +const VPN_NAME_PATTERN = /(tap|tun|wintun|wireguard|openvpn|anyconnect|cisco|zerotier|tailscale|nordlynx|hamachi|fortissl|fortinet|globalprotect|pangp|juniper|pulse|ppp|l2tp|sstp|ikev2)/i; + +function inferVpn(interfaces, routes) { + const candidates = (interfaces || []).filter( + (iface) => + iface.operstate === 'up' && + (iface.virtual === true || VPN_NAME_PATTERN.test(iface.ifaceName || '') || VPN_NAME_PATTERN.test(iface.iface || '')) + ); + const routeList = Array.isArray(routes) ? routes : routes ? [routes] : []; + const defaultRoutes = routeList.map((route) => ({ + interfaceAlias: route.InterfaceAlias, + nextHop: route.NextHop, + metric: route.RouteMetric, + })); + return { + vpnLikely: candidates.length > 0, + vpnCandidateInterfaces: candidates.map((iface) => ({ + iface: iface.iface, + ifaceName: iface.ifaceName, + virtual: iface.virtual, + type: iface.type, + ip4: iface.ip4, + })), + defaultRoutes, + }; +} + +// --------------------------------------------------------------------------- +// Probe definitions. `requiresAdmin` is what we expect on Windows; the run +// itself verifies it (values that come back empty without elevation). +// --------------------------------------------------------------------------- +function buildProbes() { + return [ + // --- Red: interfaces / gateway / uso / conexiones --- + { + group: 'red-interfaces', + attr: 'interfaces (ip4/ip6, tipo, MAC, velocidad, virtual, dhcp, dns)', + call: 'si.networkInterfaces()', + requiresAdmin: 'no', + fn: () => si.networkInterfaces(), + }, + { + group: 'red-gateway', + attr: 'gateway por defecto', + call: 'si.networkGatewayDefault()', + requiresAdmin: 'no', + fn: () => si.networkGatewayDefault(), + }, + { + group: 'red-uso', + attr: 'bytes rx/tx y tasa (2 muestras)', + call: `si.networkStats() x2 (${NETSTATS_SAMPLE_GAP_MS} ms)`, + requiresAdmin: 'no', + fn: async () => { + const first = await si.networkStats(); + await sleep(NETSTATS_SAMPLE_GAP_MS); + const second = await si.networkStats(); + return second.map((sample, i) => { + const prev = first[i] || {}; + const seconds = NETSTATS_SAMPLE_GAP_MS / 1000; + return { + iface: sample.iface, + rx_bytes: sample.rx_bytes, + tx_bytes: sample.tx_bytes, + rx_bytes_per_sec: + prev.rx_bytes != null ? Math.round((sample.rx_bytes - prev.rx_bytes) / seconds) : null, + tx_bytes_per_sec: + prev.tx_bytes != null ? Math.round((sample.tx_bytes - prev.tx_bytes) / seconds) : null, + }; + }); + }, + }, + { + group: 'red-conexiones', + attr: 'conexiones activas (coste alto, evaluar)', + call: 'si.networkConnections()', + requiresAdmin: 'parcial (PID/proceso solo elevado)', + fn: async () => { + const connections = await si.networkConnections(); + // Full list is huge and privacy-heavy; keep counts + a small sample. + return { + total: connections.length, + byState: connections.reduce((acc, c) => { + acc[c.state || 'unknown'] = (acc[c.state || 'unknown'] || 0) + 1; + return acc; + }, {}), + sample: connections.slice(0, 5), + }; + }, + }, + // --- Red: Wi-Fi --- + { + group: 'red-wifi', + attr: 'Wi-Fi conectada (ya en uso por el app)', + call: 'si.wifiConnections()', + requiresAdmin: 'no', + fn: () => si.wifiConnections(), + }, + { + group: 'red-wifi', + attr: 'redes Wi-Fi visibles (scan real)', + call: 'si.wifiNetworks()', + requiresAdmin: 'no (requiere servicio WLAN activo)', + fn: () => si.wifiNetworks(), + }, + { + group: 'red-wifi', + attr: 'adaptadores Wi-Fi', + call: 'si.wifiInterfaces()', + requiresAdmin: 'no', + fn: () => si.wifiInterfaces(), + }, + // --- Red: DNS --- + { + group: 'red-dns', + attr: 'servidores DNS configurados', + call: 'Get-DnsClientServerAddress (PowerShell)', + requiresAdmin: 'no', + fn: async () => { + const result = await runPowershellJson( + "Get-DnsClientServerAddress -AddressFamily IPv4 | Where-Object {$_.ServerAddresses} | Select-Object InterfaceAlias, ServerAddresses" + ); + return result; + }, + }, + // --- Red: VPN --- + { + group: 'red-vpn', + attr: 'detección de VPN (inferencia)', + call: 'si.networkInterfaces() + Get-NetRoute 0.0.0.0/0', + requiresAdmin: 'no', + fn: async () => { + const interfaces = await si.networkInterfaces(); + let routes = null; + try { + routes = await runPowershellJson( + "Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Select-Object InterfaceAlias, NextHop, RouteMetric" + ); + } catch (_) { + /* route table optional */ + } + return inferVpn(Array.isArray(interfaces) ? interfaces : [interfaces], routes); + }, + }, + // --- Sistema --- + { + group: 'sistema-os', + attr: 'OS (build, edición, arquitectura, hypervisor)', + call: 'si.osInfo()', + requiresAdmin: 'no', + fn: () => si.osInfo(), + }, + { + group: 'sistema-cpu', + attr: 'CPU modelo/núcleos/velocidad', + call: 'si.cpu()', + requiresAdmin: 'no', + fn: () => si.cpu(), + }, + { + group: 'sistema-cpu', + attr: 'carga actual de CPU', + call: 'si.currentLoad()', + requiresAdmin: 'no', + fn: async () => { + const load = await si.currentLoad(); + // Drop the per-core array from the stored value; keep the summary. + return { + avgLoad: load.avgLoad, + currentLoad: load.currentLoad, + currentLoadUser: load.currentLoadUser, + currentLoadSystem: load.currentLoadSystem, + cpuCount: (load.cpus || []).length, + }; + }, + }, + { + group: 'sistema-cpu', + attr: 'temperatura de CPU', + call: 'si.cpuTemperature()', + requiresAdmin: 'probable (WMI/ACPI suele requerir elevación)', + fn: () => si.cpuTemperature(), + }, + // --- Disco / memoria --- + { + group: 'sistema-disco', + attr: 'discos físicos (tipo HDD/SSD, tamaño)', + call: 'si.diskLayout()', + requiresAdmin: 'no', + fn: () => si.diskLayout(), + }, + { + group: 'sistema-disco', + attr: 'filesystems (tamaño/usado/libre)', + call: 'si.fsSize()', + requiresAdmin: 'no', + fn: () => si.fsSize(), + }, + { + group: 'sistema-memoria', + attr: 'memoria total/libre/usada', + call: 'si.mem()', + requiresAdmin: 'no', + fn: () => si.mem(), + }, + // --- Instalación / entorno de ejecución --- + { + group: 'sistema-instalacion', + attr: 'proceso corre elevado', + call: 'fltmc.exe (exit code)', + requiresAdmin: 'no', + fn: async () => ({ elevated: isProcessElevated() }), + }, + { + group: 'sistema-instalacion', + attr: 'entorno de ejecución (Node, usuario, hostname)', + call: 'os.userInfo() / process.version', + requiresAdmin: 'no', + fn: async () => ({ + nodeVersion: process.version, + hostname: os.hostname(), + username: os.userInfo().username, + windowsRelease: os.release(), + note: 'app.getAppPath() y fecha de instalación: verificar en Electron (Artefacto 2)', + }), + }, + ]; +} + +// --------------------------------------------------------------------------- +// Runner: executes one pass of every probe with timing + error capture. +// --------------------------------------------------------------------------- +async function runPass(probes) { + const results = []; + for (const probe of probes) { + const startedAt = process.hrtime.bigint(); + let value = null; + let error = null; + try { + value = await probe.fn(); + } catch (err) { + error = String((err && err.message) || err); + } + const ms = Number(process.hrtime.bigint() - startedAt) / 1e6; + results.push({ ...probe, fn: undefined, value, error, ms: Math.round(ms) }); + const status = error ? 'ERROR' : isEmptyValue(value) ? 'vacío' : 'ok'; + console.log(` ${probe.call.padEnd(50)} ${String(Math.round(ms)).padStart(6)} ms ${status}`); + } + return results; +} + +function isEmptyValue(value) { + if (value == null) return true; + if (Array.isArray(value)) return value.length === 0; + if (typeof value === 'object') { + const keys = Object.keys(value); + if (keys.length === 0) return true; + return keys.every((key) => value[key] == null || value[key] === '' || value[key] === -1); + } + return value === ''; +} + +/** 'sí' | 'no' | 'parcial' for the CSV. */ +function availability(entry) { + if (entry.error) return 'no'; + if (isEmptyValue(entry.value)) return 'no'; + const value = entry.value; + const flatValues = []; + (function walk(node) { + if (node == null) return flatValues.push(null); + if (Array.isArray(node)) return node.forEach(walk); + if (typeof node === 'object') return Object.values(node).forEach(walk); + flatValues.push(node); + })(value); + if (flatValues.length === 0) return 'parcial'; // structure exists but all values null/empty + const emptyish = flatValues.filter((v) => v === '' || v === null || v === -1).length; + return emptyish > flatValues.length / 2 ? 'parcial' : 'sí'; +} + +// --------------------------------------------------------------------------- +// Redaction — the raw dump stays on the machine; everything shared is masked. +// --------------------------------------------------------------------------- +const IP4_RE = /^\d{1,3}(\.\d{1,3}){3}$/; +const MAC_RE = /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i; +const SENSITIVE_KEY_RE = /(ssid|bssid|mac|ip4|ip6|address|user|host|fqdn|serial|uuid|gateway|dns|nexthop)/i; + +function maskString(key, raw) { + const value = String(raw); + if (IP4_RE.test(value)) return value.split('.')[0] + '.x.x.x'; + if (MAC_RE.test(value)) return value.slice(0, 8) + ':xx:xx:xx'; + if (/ssid/i.test(key)) return 'SSID-' + shortHash(value); + if (value.includes('::') || /^[0-9a-f:]{6,}$/i.test(value)) return '«ipv6»'; + return '«' + key.toLowerCase() + '-' + shortHash(value) + '»'; +} + +function shortHash(text) { + let hash = 0; + for (let i = 0; i < text.length; i++) hash = ((hash << 5) - hash + text.charCodeAt(i)) | 0; + return Math.abs(hash).toString(16).slice(0, 4); +} + +function redact(node, parentKey = '') { + if (node == null) return node; + // Bare strings that look like an IP or MAC get masked regardless of key + // (e.g. networkGatewayDefault() returns a plain string). + if (typeof node === 'string' && (IP4_RE.test(node) || MAC_RE.test(node))) { + return maskString(parentKey || 'value', node); + } + if (Array.isArray(node)) return node.map((item) => redact(item, parentKey)); + if (typeof node === 'object') { + const out = {}; + for (const [key, value] of Object.entries(node)) { + if (typeof value === 'string' && value !== '' && SENSITIVE_KEY_RE.test(key)) { + out[key] = maskString(key, value); + } else if (Array.isArray(value) && SENSITIVE_KEY_RE.test(key)) { + out[key] = value.map((item) => + typeof item === 'string' ? maskString(key, item) : redact(item, key) + ); + } else { + out[key] = redact(value, key); + } + } + return out; + } + return node; +} + +// --------------------------------------------------------------------------- +// CSV — one row per attribute; this is the table for the UNICEF spreadsheet. +// --------------------------------------------------------------------------- +function csvEscape(text) { + return '"' + String(text == null ? '' : text).replace(/"/g, '""') + '"'; +} + +function sampleValue(entry) { + if (entry.error) return 'ERROR: ' + entry.error.slice(0, 120); + const redacted = redact(entry.value); + let text = JSON.stringify(redacted); + if (text && text.length > 220) text = text.slice(0, 220) + '…'; + return text; +} + +function buildCsv(rows) { + const header = [ + 'grupo', + 'atributo', + 'llamada', + 'disponible', + 'valor de ejemplo (redactado)', + 'ms', + 'requiere admin', + 'volátil', + 'notas', + ]; + const lines = [header.map(csvEscape).join(',')]; + for (const row of rows) { + lines.push( + [ + row.group, + row.attr, + row.call, + row.disponible, + row.ejemplo, + row.ms, + row.requiresAdmin, + row.volatil, + row.notas, + ] + .map(csvEscape) + .join(',') + ); + } + return lines.join('\r\n') + '\r\n'; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +async function main() { + const hostname = os.hostname(); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const baseName = `probe-${hostname}-${timestamp}`; + const elevated = isProcessElevated(); + + console.log(`\nProbe de red/sistema — plan 0008 (release v2.0.4)`); + console.log(`Equipo: ${hostname} | Node ${process.version} | elevado: ${elevated ? 'sí' : 'no'}`); + console.log(`\nPasada 1/2:`); + const probes = buildProbes(); + const pass1 = await runPass(probes); + + console.log(`\nEsperando ${PASS_DELAY_MS / 1000}s para la pasada de volatilidad…`); + await sleep(PASS_DELAY_MS); + + console.log(`\nPasada 2/2:`); + const pass2 = await runPass(buildProbes()); + + const rows = pass1.map((entry, i) => { + const second = pass2[i]; + const changed = + !entry.error && !second.error && JSON.stringify(entry.value) !== JSON.stringify(second.value); + const notes = []; + if (entry.error) notes.push('falló en pasada 1'); + if (second.error && !entry.error) notes.push('falló solo en pasada 2 (inestable)'); + if (Math.max(entry.ms, second.ms) > 1000) + notes.push(`lento (peor pasada: ${Math.max(entry.ms, second.ms)} ms)`); + return { + group: entry.group, + attr: entry.attr, + call: entry.call, + disponible: availability(entry), + ejemplo: sampleValue(entry), + ms: Math.round((entry.ms + second.ms) / 2), + requiresAdmin: entry.requiresAdmin, + volatil: changed ? 'sí' : 'no', + notas: notes.join('; '), + }; + }); + + const rawDump = { + meta: { + hostname, + timestamp: new Date().toISOString(), + nodeVersion: process.version, + windowsRelease: os.release(), + elevated, + passDelayMs: PASS_DELAY_MS, + }, + pass1: pass1.map(({ fn, ...rest }) => rest), + pass2: pass2.map(({ fn, ...rest }) => rest), + }; + + const outDir = process.cwd(); + const rawPath = path.join(outDir, `${baseName}.json`); + const redactedPath = path.join(outDir, `${baseName}-redacted.json`); + const csvPath = path.join(outDir, `${baseName}.csv`); + + fs.writeFileSync(rawPath, JSON.stringify(rawDump, null, 2)); + fs.writeFileSync(redactedPath, JSON.stringify(redact(rawDump), null, 2)); + fs.writeFileSync(csvPath, buildCsv(rows)); + + // Console summary: failures + worst timings. + const failures = pass1.filter((entry) => entry.error); + const slowest = [...pass1].sort((a, b) => b.ms - a.ms).slice(0, 5); + console.log('\n================ RESUMEN ================'); + console.log(`Atributos probados: ${pass1.length} | fallos: ${failures.length}`); + for (const failure of failures) console.log(` FALLO ${failure.call}: ${failure.error}`); + console.log('Llamadas más lentas (pasada 1):'); + for (const entry of slowest) console.log(` ${String(entry.ms).padStart(6)} ms ${entry.call}`); + console.log('\nArchivos generados:'); + console.log(` ${rawPath}`); + console.log(` ${redactedPath}`); + console.log(` ${csvPath}`); + console.log( + '\nAVISO: el JSON crudo contiene SSIDs, MACs, IPs internas y el usuario de\n' + + 'Windows. NO lo compartas fuera del equipo sin revisarlo; adjunta al\n' + + 'ticket/spreadsheet la versión -redacted.json y el CSV.' + ); +} + +main().catch((err) => { + console.error('El probe terminó con un error no controlado:', err); + process.exit(1); +}); From d99dda356abbf0e8a7388708af61c0f214b534ba Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Tue, 11 Aug 2026 11:54:15 +0200 Subject: [PATCH 10/22] feat(research): run the probe inside the Electron main process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make probe-system-info.js requirable (exports main), tag output files with the runtime (node vs electron-), honor PROBE_OUT_DIR, and add electron-probe-main.js to run the probe on Electron's embedded Node — Artefacto 2 of plan 0008, unpackaged variant. Co-Authored-By: Claude Fable 5 --- scripts/research/electron-probe-main.js | 31 +++++++++++++++++++++++++ scripts/research/probe-system-info.js | 26 +++++++++++++++------ 2 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 scripts/research/electron-probe-main.js diff --git a/scripts/research/electron-probe-main.js b/scripts/research/electron-probe-main.js new file mode 100644 index 00000000..7c559946 --- /dev/null +++ b/scripts/research/electron-probe-main.js @@ -0,0 +1,31 @@ +/** + * electron-probe-main.js — Artefacto 2 del plan 0008. + * + * Runs probe-system-info.js inside an Electron MAIN PROCESS, so the calls run + * on Electron's embedded Node (not the system Node) — the same runtime the + * app's ipcMain handlers use. From the repo root: + * + * npx electron scripts/research/electron-probe-main.js + * + * Optionally set PROBE_OUT_DIR to choose where the output files go. + * + * Caveat: this is the unpackaged runtime. The last step of the plan is still + * to verify the finalist attributes in the *packaged* app (temporary + * `ipcMain.handle('research-probe', …)` in electron/src/index.ts). + */ + +'use strict'; + +const { app } = require('electron'); +const { main } = require('./probe-system-info.js'); + +app.whenReady().then(async () => { + let exitCode = 0; + try { + await main(); + } catch (err) { + console.error('El probe falló dentro de Electron:', err); + exitCode = 1; + } + app.exit(exitCode); +}); diff --git a/scripts/research/probe-system-info.js b/scripts/research/probe-system-info.js index 2c31a33a..67abf944 100644 --- a/scripts/research/probe-system-info.js +++ b/scripts/research/probe-system-info.js @@ -468,11 +468,16 @@ function buildCsv(rows) { async function main() { const hostname = os.hostname(); const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - const baseName = `probe-${hostname}-${timestamp}`; + const runtime = process.versions.electron ? `electron-${process.versions.electron}` : 'node'; + const baseName = `probe-${hostname}-${runtime}-${timestamp}`; const elevated = isProcessElevated(); console.log(`\nProbe de red/sistema — plan 0008 (release v2.0.4)`); - console.log(`Equipo: ${hostname} | Node ${process.version} | elevado: ${elevated ? 'sí' : 'no'}`); + console.log( + `Equipo: ${hostname} | Node ${process.version}` + + (process.versions.electron ? ` (Electron ${process.versions.electron}, main process)` : '') + + ` | elevado: ${elevated ? 'sí' : 'no'}` + ); console.log(`\nPasada 1/2:`); const probes = buildProbes(); const pass1 = await runPass(probes); @@ -510,6 +515,7 @@ async function main() { hostname, timestamp: new Date().toISOString(), nodeVersion: process.version, + electronVersion: process.versions.electron || null, windowsRelease: os.release(), elevated, passDelayMs: PASS_DELAY_MS, @@ -518,7 +524,7 @@ async function main() { pass2: pass2.map(({ fn, ...rest }) => rest), }; - const outDir = process.cwd(); + const outDir = process.env.PROBE_OUT_DIR || process.cwd(); const rawPath = path.join(outDir, `${baseName}.json`); const redactedPath = path.join(outDir, `${baseName}-redacted.json`); const csvPath = path.join(outDir, `${baseName}.csv`); @@ -546,7 +552,13 @@ async function main() { ); } -main().catch((err) => { - console.error('El probe terminó con un error no controlado:', err); - process.exit(1); -}); +// Run directly (`node probe-system-info.js`) or require it from an Electron +// main process (Artefacto 2 del plan 0008) and await `main()` there. +if (require.main === module) { + main().catch((err) => { + console.error('El probe terminó con un error no controlado:', err); + process.exit(1); + }); +} else { + module.exports = { main }; +} From 913e0f69a537362c99ccf749de58c23b1f418b95 Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Mon, 17 Aug 2026 16:23:12 +0200 Subject: [PATCH 11/22] test(e2e): Playwright happy-path suite for the v2.0.4 RC Automates the 8-step manual checklist of plan 0010 against a real backend, replacing the dead Protractor scaffold (`ng e2e` pointed at Protractor 7 and there was no runnable e2e at all). Covered, in serial over one install (the checklist is a linear walk and the state accumulates): clean start -> school registration -> first ndt7 test (real, against M-Lab) -> realtime upload -> manual test -> scheduled slot -> post-measurement UI -> restart persistence. The plan 0006 fields (upload_failed, scheduled_slot, scheduled_at) are checked in the DB column, not just in the POST payload (e2e/playwright/db.ts queries Postgres through the container, so no new dependency). Notable bits: - Waiting for a real slot would take hours, so step 6 injects an expired semaphore for slot A and lets the scheduler's 60s tick pick it up, as it would in production. scheduledTesting has to be enabled first or getSemaphore() wipes the semaphore on every tick. - Fixtures live here, not in giga-meter-backend: its seed-runner.ts and Spain seed only exist on the develop line, which tied the suite to that branch. e2e/seed/{seed.js,seed-spain.sql} are mounted into the container instead, so the suite runs against develop and staging alike. - The compose sets DIRECT_DATABASE_URL: some branches declare `directUrl` in the Prisma datasource and the backend dies with P1012 without it. Verified green (5 passed) against both backend lines: develop and staging, each with the plan 0006 migration applied. `npm run e2e:stg` runs the same spec against the real Azure staging, skipping the DB assertions. Not exercised yet: it writes real data to a shared environment. Plan: project-memory/plans/0010-e2e-happy-path-test-rc-2.0.4.md (giga repo) Co-Authored-By: Claude Opus 5 --- .gitignore | 5 + angular.json | 11 + e2e/README.md | 131 ++++++++ e2e/docker-compose.e2e.yml | 116 +++++++ e2e/playwright/db.ts | 78 +++++ e2e/playwright/happy-path.spec.ts | 438 +++++++++++++++++++++++++++ e2e/seed/e2e-category-config.sql | 23 ++ e2e/seed/seed-spain.sql | 76 +++++ e2e/seed/seed.js | 65 ++++ package-lock.json | 64 ++++ package.json | 7 +- playwright.config.ts | 45 +++ playwright.stg.config.ts | 60 ++++ src/environments/_environment.e2e.ts | 21 ++ 14 files changed, 1139 insertions(+), 1 deletion(-) create mode 100644 e2e/README.md create mode 100644 e2e/docker-compose.e2e.yml create mode 100644 e2e/playwright/db.ts create mode 100644 e2e/playwright/happy-path.spec.ts create mode 100644 e2e/seed/e2e-category-config.sql create mode 100644 e2e/seed/seed-spain.sql create mode 100644 e2e/seed/seed.js create mode 100644 playwright.config.ts create mode 100644 playwright.stg.config.ts create mode 100644 src/environments/_environment.e2e.ts diff --git a/.gitignore b/.gitignore index 3f11cf13..c8e0a68d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,8 @@ npm-debug.log* _environment.prod.ts electron/electron-builder.env src/environments/_environment.prod.ts + +# Playwright e2e artifacts +/test-results +/e2e/playwright-report +/e2e/playwright-report-stg diff --git a/angular.json b/angular.json index 27f2ac56..96adebdd 100644 --- a/angular.json +++ b/angular.json @@ -74,6 +74,14 @@ }, "ci": { "progress": false + }, + "e2e": { + "fileReplacements": [ + { + "replace": "src/environments/_environment.prod.ts", + "with": "src/environments/_environment.e2e.ts" + } + ] } } }, @@ -88,6 +96,9 @@ }, "ci": { "progress": false + }, + "e2e": { + "buildTarget": "app:build:e2e" } } }, diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..53b0a951 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,131 @@ +# E2E — happy path (Playwright) + +Suite Playwright del happy path del Daily Check App corriendo en navegador +(`ng serve`) contra el backend real en docker. Corresponde al plan 0010 del +`project-memory` del workspace. + +> La carpeta `e2e/src` + `protractor.conf.js` son restos de Protractor +> (descontinuado) y no se usan. + +## Qué cubre + +Los 8 pasos del checklist del plan 0010, en serie sobre la misma instalación +(comparten página: el checklist es un recorrido lineal y el estado se acumula): + +| # | Paso | Verificación | +|---|------|--------------| +| 1-4 | Instalación limpia → registro (España / `ES-TEST-SCHOOL-01`) → primer test ndt7 **real** → upload en tiempo real | payload y fila en DB con `upload_failed=false`, slot `null`; cola offline vacía | +| 5 | Test manual desde el medidor | fila con `scheduled_slot`/`scheduled_at` en `null` | +| 6 | Slot programado | fila con `scheduled_slot='A'` y `scheduled_at` = la hora planificada, sin reintentos | +| 7 | UI post-medición | historial local + cifras visibles en la tarjeta de última medición | +| 8 | Reinicio | el registro persiste (sin onboarding) y el scheduler se rearma | + +El paso 6 no espera a un slot real (serían horas): inyecta un semáforo vencido +para el slot A y deja que lo recoja el tick de 60 s del scheduler, como en +producción. Los tres campos del plan 0006 se comprueban **en columna de DB** +(`e2e/playwright/db.ts`, vía `psql` en el contenedor), no solo en el payload. + +Lo que NO cubre (por diseño): el main process de Electron +(`systeminformation`, wifi, hardware id — llegan como `null`/`'N/A'`), el +instalador, y el camino de fallo de upload → sync (QA del plan 0006). + +## Requisitos + +- Docker Desktop corriendo. +- `giga-meter-backend` como repo hermano (layout del workspace giga). +- Internet (el speed test ndt7 corre contra servidores M-Lab reales). +- Una vez: `npx playwright install chromium`. + +### Rama del backend hermano + +El stack construye el backend desde el **working tree** del repo hermano, así +que la suite corre contra la rama que tengas ahí checked out. Funciona con +`develop` y con `staging` (verificado en ambas, 2026-08-17). + +Los fixtures viven en `e2e/seed/` de **este** repo y se montan en el +contenedor, para no depender de que el backend traiga el tooling de seed: + +- `seed.js` — cargador con `pg`. Sustituye a `src/prisma/scripts/seed-runner.ts` + del backend, que solo existe en la línea de `develop` (lo añadió el commit + `ef07c5b` del trabajo health-entity y nunca llegó a `staging`). +- `seed-spain.sql` — fixture ES mínimo: `country`, `dailycheckapp_country` y la + escuela `ES-TEST-SCHOOL-01`. Nada de `facility_type`, whitelist ni `health`: + eso es del multi-facility (plan 0003), sus tablas no existen en `staging` y el + RC 2.0.4 solo hace el flujo school sobre `/api/v1`. + +Única condición extra: para que pasen las aserciones en DB de +`upload_failed`/`scheduled_slot`/`scheduled_at`, la rama del backend necesita la +migración del plan 0006. + +## Contra el staging real + +```bash +npm run e2e:stg +``` + +Usa `playwright.stg.config.ts`: no levanta docker, sirve el app con `npm start` +(sin la config `e2e`, así que `_environment.prod.ts` manda — está en +`mode: 'stg'` y resuelve `restAPIStg` + `tokenStg`) y corre el mismo spec con +`E2E_SKIP_DB=1`, que omite las comprobaciones en columna porque no hay +contenedor de Postgres. Quedan las de payload, storage y UI. + +**Escribe datos reales**: un registro de dispositivo y hasta 3 mediciones ndt7 +por corrida, en un entorno compartido. No es desechable. + +## Correr + +```bash +npm run e2e +``` + +Playwright levanta solo los dos servidores (config `webServer`): + +1. `docker compose -f e2e/docker-compose.e2e.yml up --build` — Postgres + (PostGIS, puerto host 55432) + Redis + backend en `:3000` + un mock del + servicio de validación de api keys de Project Connect (acepta cualquier + token con write access y categoría `giga_meter`). Al arrancar aplica + migraciones y los seeds idempotentes (`seed-spain-project-connect.sql` + + `e2e/seed/e2e-category-config.sql`). La primera vez el build de la imagen + tarda varios minutos. +2. `npm run start:e2e` — `ng serve --configuration e2e`, que reemplaza + `_environment.prod.ts` por `src/environments/_environment.e2e.ts` + (API → `http://localhost:3000/api/v1/`). + +Ambos usan `reuseExistingServer`: si ya los tienes levantados a mano, los +reutiliza. Para bajar y limpiar el stack docker (borra la DB): + +```bash +npm run e2e:down +``` + +Modo visible / debug: + +```bash +npm run e2e:headed +``` + +```bash +npx playwright test --debug +``` + +Reporte HTML tras un fallo: `e2e/playwright-report/` (trace y video se +guardan solo en fallos). + +## Notas de estabilidad + +- `api.ipinfo.io` y `ipv4.geojs.io` van interceptados con respuestas fijas + (evita ~14 s de reintentos y flakiness). +- El **startup test** se silencia sembrando `startupTestScheduled`/ + `lastStartupTest`/`lastMeasurement` en localStorage antes de cargar la app, + para que su delay aleatorio de 0-15 min no compita con los tests del + checklist. El scheduler en sí sigue vivo: el paso 6 lo necesita. +- `scheduledTesting` viene desactivado por defecto; el paso 6 lo habilita en + `savedSettings` antes de inyectar el semáforo, o `getSemaphore()` lo vaciaría + en cada tick. +- El primer test dispara un modal de felicitación que tapa el medidor: el paso 5 + lo cierra antes de pinchar. +- La suite completa tarda ~4-5 min (cada speed test real dura ~25-45 s y el + paso 6 espera hasta un minuto al tick del scheduler). +- El compose define `DIRECT_DATABASE_URL` además de `DATABASE_URL`: algunas + ramas declaran `directUrl` en el datasource de Prisma y sin ella el backend + no arranca (P1012). diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml new file mode 100644 index 00000000..56ae7205 --- /dev/null +++ b/e2e/docker-compose.e2e.yml @@ -0,0 +1,116 @@ +# Backend stack for the Playwright e2e suite: Postgres (PostGIS) + Redis + +# giga-meter-backend built from the sibling repo in the giga workspace. +# +# Layout requirement: this repo and giga-meter-backend must be siblings +# (giga-workspace/giga/{project-connect-daily-check-app,giga-meter-backend}). +# +# Started automatically by playwright.config.ts (webServer). Manual usage: +# docker compose -f e2e/docker-compose.e2e.yml up --build +# npm run e2e:down # tears it down and drops the DB +# +# On every start the backend container applies migrations and re-runs the +# idempotent Spain seed (country ES + school ES-TEST-SCHOOL-01), so the suite +# always finds its fixture data. +name: giga-meter-e2e + +services: + e2e-db: + # PostGIS build: the backend migrations/seeds use the postgis extension + # and ST_MakePoint (same image as the workspace docker-compose-local.yml). + image: postgis/postgis:15-3.3 + environment: + POSTGRES_USER: giga + POSTGRES_PASSWORD: giga + POSTGRES_DB: giga_e2e + ports: + - '55432:5432' # offset so it never clashes with a dev postgres on 5432 + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U giga -d giga_e2e'] + interval: 5s + timeout: 5s + retries: 10 + + e2e-redis: + image: redis:7 + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 5s + retries: 10 + + e2e-auth-mock: + # Stand-in for the Project Connect service the AuthGuard calls to validate + # bearer tokens (GET /api/v1/validate_api_key/DAILY_CHECK_APP). Accepts any + # token and grants write access with category giga_meter, so the backend + # can run with USE_AUTH=true without reaching Azure. + image: node:22-alpine + command: + - node + - -e + - | + const http = require('http'); + const body = JSON.stringify({ + success: true, + message: 'ok', + timestamp: new Date().toISOString(), + data: { + has_write_access: true, + countries: [], + apiCategory: { code: 'giga_meter' }, + }, + }); + http.createServer((req, res) => { + res.setHeader('content-type', 'application/json'); + res.end(body); + }).listen(80, () => console.log('e2e auth mock listening on :80')); + + e2e-backend: + build: + context: ../../giga-meter-backend + args: + # The backend Dockerfile pipes this into chpasswd (ssh access for the + # Azure deployment) and fails on an empty value. Never exposed here: + # the e2e container doesn't publish the ssh port. + SSH_PASSWD: 'root:e2e-local-only' + depends_on: + e2e-db: + condition: service_healthy + e2e-redis: + condition: service_healthy + e2e-auth-mock: + condition: service_started + environment: + DATABASE_URL: postgresql://giga:giga@e2e-db:5432/giga_e2e?schema=public + # Algunas ramas del backend declaran `directUrl = env("DIRECT_DATABASE_URL")` + # en el datasource de Prisma y sin la variable el arranque muere con P1012. + # Aquí no hay pooler, así que apunta a la misma DB; sobra sin hacer daño en + # las ramas cuyo schema no la usa. + DIRECT_DATABASE_URL: postgresql://giga:giga@e2e-db:5432/giga_e2e?schema=public + REDIS_URL: redis://e2e-redis:6379 + NODE_ENV: development # dev mode => CORS allows the ng serve origin + # Real auth path, fake validator: several endpoints gate on the + # write_access/category that only AuthGuard.validateToken sets, so + # USE_AUTH=false is not enough — validate against the local mock instead. + USE_AUTH: 'true' + PROJECT_CONNECT_SERVICE_URL: http://e2e-auth-mock + DAILY_CHECK_APP_API_CODE: DAILY_CHECK_APP + PCDC_APP_DOWNLOAD_URL: http://unused.invalid + CACHE_EXPIRE: '60' + ports: + - '3000:3000' + volumes: + # Fixtures y cargador viven en ESTE repo, no en el backend: el seed-runner + # y el seed de España del backend solo existen en la línea de `develop` + # (los añadió el trabajo health-entity), así que depender de ellos ataba la + # suite a esa rama. Montados aquí, funciona con cualquier rama del backend. + - ./seed:/APP/e2e-seed:ro + # La imagen arranca por defecto con start.sh (ssh + boot de prod); se + # sustituye por migrate -> seed -> serve. `node` y `pg` salen de /APP. + entrypoint: + - /bin/sh + - -c + - > + npx prisma migrate deploy && + node /APP/e2e-seed/seed.js + /APP/e2e-seed/seed-spain.sql /APP/e2e-seed/e2e-category-config.sql && + npm run start:prod diff --git a/e2e/playwright/db.ts b/e2e/playwright/db.ts new file mode 100644 index 00000000..42d1d50a --- /dev/null +++ b/e2e/playwright/db.ts @@ -0,0 +1,78 @@ +// Consultas a la DB del stack e2e (plan 0010, pasos 5-6: "verificar en DB"). +// +// Se habla con Postgres vía `docker compose exec psql` en vez de un cliente +// node: el stack de docker ya es requisito duro de la suite, así que esto no +// añade ninguna dependencia nueva al repo del app. +// +// Las queries devuelven JSON generado por Postgres y se parsean con JSON.parse, +// así no hay que elegir separador de campos ni escapar la salida de psql. +import { execFileSync } from 'node:child_process'; + +const COMPOSE_FILE = 'e2e/docker-compose.e2e.yml'; +const DB_SERVICE = 'e2e-db'; + +// Formato que produce exactamente lo mismo que Date#toISOString() en JS, para +// poder comparar scheduled_at contra el timestamp inyectado en el semáforo. +const ISO_MS = `'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'`; + +/** Corre una query que devuelve una sola columna JSON y la parsea. */ +function queryJson(sql: string): T { + const stdout = execFileSync( + 'docker', + [ + 'compose', + '-f', + COMPOSE_FILE, + 'exec', + '-T', + DB_SERVICE, + 'psql', + '-U', + 'giga', + '-d', + 'giga_e2e', + '-t', // solo tuplas, sin cabecera + '-A', // sin alineación ni padding + '-c', + sql, + ], + { encoding: 'utf8' }, + ); + return JSON.parse(stdout.trim()); +} + +export interface MeasurementRow { + notes: string | null; + upload_failed: boolean | null; + scheduled_slot: string | null; + scheduled_at: string | null; + giga_id_school: string | null; +} + +/** + * Última fila de `measurements` que cumple la condición, o null si no hay + * ninguna. `where` se interpola tal cual: solo se llama con literales del test. + */ +export function latestMeasurement(where: string): MeasurementRow | null { + return queryJson( + `SELECT coalesce(to_json(t), 'null'::json) + FROM ( + SELECT notes, + upload_failed, + scheduled_slot, + to_char(scheduled_at AT TIME ZONE 'UTC', ${ISO_MS}) AS scheduled_at, + giga_id_school + FROM measurements + WHERE ${where} + ORDER BY id DESC + LIMIT 1 + ) t;`, + ); +} + +/** Cuántas mediciones hay para la escuela del fixture. */ +export function measurementCount(gigaIdSchool: string): number { + return queryJson( + `SELECT to_json(count(*)) FROM measurements WHERE giga_id_school = '${gigaIdSchool}';`, + ); +} diff --git a/e2e/playwright/happy-path.spec.ts b/e2e/playwright/happy-path.spec.ts new file mode 100644 index 00000000..eea9f877 --- /dev/null +++ b/e2e/playwright/happy-path.spec.ts @@ -0,0 +1,438 @@ +import { test, expect, Browser, Page } from '@playwright/test'; +import { latestMeasurement, measurementCount } from './db'; + +// Happy path del Daily Check App (plan 0010): instalación limpia → registro de +// escuela → primer test automático → upload en tiempo real → test manual → +// slot programado → UI → reinicio. +// +// Los pasos comparten una única página a propósito: el checklist es un recorrido +// lineal sobre la misma instalación (localStorage + IndexedDB + filas en la DB +// se acumulan), así que corren en serie y sobre el mismo contexto de browser. +// +// Fixtures: seed-spain-project-connect.sql (aplicado por el compose de e2e). +test.describe.configure({ mode: 'serial' }); + +// Por defecto, el stack local de docker. `playwright.stg.config.ts` reapunta +// estas variables al staging real, donde no hay contenedor de DB al que +// consultar (E2E_SKIP_DB) y la escuela es una de verdad, con un giga id que no +// conocemos de antemano. +const API = process.env.E2E_API ?? 'http://localhost:3000/api/v1/'; +const COUNTRY_NAME = process.env.E2E_COUNTRY ?? 'Spain'; +const SCHOOL_EXTERNAL_ID = process.env.E2E_SCHOOL_ID ?? 'ES-TEST-SCHOOL-01'; +// Vacío = no se conoce el giga id esperado; se comprueba solo que sea no vacío +// y coherente entre storage y payload. +const EXPECTED_GIGA_ID = + process.env.E2E_GIGA_ID ?? '11111111-1111-4111-8111-111111111111'; +const SKIP_DB = process.env.E2E_SKIP_DB === '1'; + +// La app consulta ipinfo/geojs con reintentos de hasta ~14 s; respuestas fijas +// mantienen el test rápido y determinista sin tocar el backend real. +const FAKE_IP_INFO = { + ip: '83.56.0.10', + asn: 'AS3352', + as_name: 'Telefonica de Espana', + country: 'ES', + country_code: 'ES', + continent: 'EU', +}; +const FAKE_GEOJS = { + ip: '83.56.0.10', + country: 'Spain', + country_code: 'ES', + latitude: '40.4168', + longitude: '-3.7038', + organization_name: 'Telefonica de Espana', +}; +// Shape de IpInfoData que network.service espera de GET /api/v1/ip-metadata/:ip. +// Se mockea porque el backend containerizado resuelve esto llamando a ipinfo +// real (lento/no determinista) y el ion-loading de searchcountry no se cierra +// hasta que responde. +const FAKE_IP_METADATA = { + ip: '83.56.0.10', + hostname: 'e2e.local', + city: 'Madrid', + region: 'Madrid', + country: 'ES', + loc: '40.4168,-3.7038', + org: 'AS3352 Telefonica de Espana', + postal: '28001', + timezone: 'Europe/Madrid', +}; + +// El tick del scheduler corre cada 60 s (app.component), así que un test que +// dependa de él necesita al menos ese minuto además del ndt7 real. +const SCHEDULER_TICK = 60_000; +const NDT7_UPLOAD_TIMEOUT = 240_000; + +// Ionic mantiene las páginas anteriores en el DOM (ion-page-hidden), así que +// todo selector debe filtrar por :visible o matchea copias ocultas. +function visibleButton(page: Page, text: string) { + return page.locator('ion-button:visible', { hasText: text }).first(); +} + +// Los ion-loading de la app (hasta 15 s en schooldetails) bloquean los clics. +async function waitForLoaderGone(page: Page): Promise { + await page + .locator('ion-loading') + .first() + .waitFor({ state: 'attached', timeout: 2_000 }) + .catch(() => undefined); // puede no llegar a mostrarse + await page.waitForFunction( + () => document.querySelectorAll('ion-loading').length === 0, + undefined, + { timeout: 30_000 }, + ); +} + +/** + * Cierra el modal de "primer test completado" si está abierto: aparece solo + * tras el primer test de una instalación nueva y tapa el medidor. + */ +async function dismissModalIfOpen(page: Page): Promise { + const modal = page.locator('ion-modal:visible').first(); + if (!(await modal.isVisible().catch(() => false))) return; + await modal.locator('button.close-button').first().click(); + await modal.waitFor({ state: 'hidden', timeout: 10_000 }); +} + +/** + * Espera a que la medición aterrice en la DB (el POST responde antes del + * commit). Devuelve null cuando se corre contra un backend remoto, donde no hay + * contenedor de Postgres al que consultar. + */ +async function expectMeasurementRow(where: string) { + if (SKIP_DB) return null; + await expect + .poll(() => latestMeasurement(where), { timeout: 15_000 }) + .not.toBeNull(); + return latestMeasurement(where)!; +} + +let page: Page; + +test.beforeAll(async ({ browser }: { browser: Browser }) => { + page = await browser.newPage(); + + await page.route('**/api.ipinfo.io/**', (route) => + route.fulfill({ json: FAKE_IP_INFO }), + ); + await page.route('**/ipv4.geojs.io/**', (route) => + route.fulfill({ json: FAKE_GEOJS }), + ); + await page.route('**/api/v1/ip-metadata/**', (route) => + route.fulfill({ json: FAKE_IP_METADATA }), + ); + + // Silenciar el startup test (delay aleatorio de 0-15 min) para que no compita + // con los tests que dispara el checklist. `lastStartupTest` de hoy hace que + // scheduleStartupTestIfNeeded lo dé por corrido en todos los ticks siguientes. + await page.addInitScript(() => { + const now = String(Date.now()); + localStorage.setItem('startupTestScheduled', now); + localStorage.setItem('lastStartupTest', now); + localStorage.setItem('lastMeasurement', now); + }); +}); + +test.afterAll(async () => { + await page?.close(); +}); + +test('pasos 1-4: registro de escuela → primer test → upload con upload_failed=false', async () => { + // ── 1. Instalación limpia: home es la pantalla de onboarding ── + await page.goto('/#/home'); + await waitForLoaderGone(page); // loader de 6 s de home + await visibleButton(page, 'Next').click(); + + // ── 2. Intro de registro: 3 slides + checkbox de privacidad ── + await waitForLoaderGone(page); + await visibleButton(page, 'Next').click(); + await visibleButton(page, 'Next').click(); + await page.locator('ion-checkbox[name="privacy"]:visible').click(); + await visibleButton(page, 'Start Registration').click(); + + // ── 3. País: buscar, elegir y validar contra el backend local ── + await waitForLoaderGone(page); // loader de detección de red del país + await page.locator('ion-searchbar input:visible').fill(COUNTRY_NAME); + await page + .locator('ion-item.dropdown_list:visible', { hasText: COUNTRY_NAME }) + .first() + .click(); + // La validación (GET dailycheckapp_countries/ES) muestra un spinner. + await page + .locator('ion-spinner:visible') + .waitFor({ state: 'hidden', timeout: 15_000 }) + .catch(() => undefined); + const confirmBtn = visibleButton(page, 'Confirm'); + await expect(confirmBtn).toBeEnabled(); + await confirmBtn.click(); + + // ── 4. Escuela: búsqueda por ID contra el backend local ── + await waitForLoaderGone(page); + await page.locator('input.searchTerm:visible').fill(SCHOOL_EXTERNAL_ID); + await visibleButton(page, 'Search ID').click(); + + // ── 5. Detalle: resultado único → Select ── + await waitForLoaderGone(page); + await expect(page.locator('ion-item.single_school:visible')).toBeVisible(); + await visibleButton(page, 'Select').click(); + + // ── 6. Confirmar: POST dailycheckapp_schools y navegación a starttest ── + const registrationResponse = page.waitForResponse( + (resp) => + resp.url().startsWith(`${API}dailycheckapp_schools`) && + resp.request().method() === 'POST', + { timeout: 30_000 }, + ); + // El upload llega ~40 s después del registro (test ndt7 real): dejar los + // waiters armados antes de confirmar. + const measurementResponse = page.waitForResponse( + (resp) => + resp.url() === `${API}measurements` && + resp.request().method() === 'POST', + { timeout: NDT7_UPLOAD_TIMEOUT }, + ); + await waitForLoaderGone(page); + await page.locator('ion-button.yesbtn:visible', { hasText: 'Yes' }).click(); + + const registration = await registrationResponse; + expect(registration.ok()).toBe(true); + const registrationBody = await registration.json(); + const userId = registrationBody?.data?.user_id; + expect(userId).toBeTruthy(); + + // Registro persistido en localStorage y app en la pantalla de test. + await page.waitForURL('**/starttest', { timeout: 15_000 }); + const stored = await page.evaluate(() => ({ + schoolId: localStorage.getItem('schoolId'), + gigaId: localStorage.getItem('gigaId'), + schoolUserId: localStorage.getItem('schoolUserId'), + })); + expect(stored.schoolId).toBeTruthy(); + if (EXPECTED_GIGA_ID) { + expect(stored.gigaId).toBe(EXPECTED_GIGA_ID); + } else { + expect(stored.gigaId).toBeTruthy(); + } + expect(String(stored.schoolUserId)).toBe(String(userId)); + + // ── 7. Primer test automático (ndt7 real contra M-Lab) → upload ── + const upload = await measurementResponse; + expect(upload.ok()).toBe(true); + + const payload = upload.request().postDataJSON(); + expect(payload.upload_failed).toBe(false); // camino feliz: subida en tiempo real + expect(payload.scheduled_slot).toBeNull(); // primer test, no programado + expect(payload.scheduled_at).toBeNull(); + expect(payload.giga_id_school).toBe(stored.gigaId); + expect(String(payload.BrowserID)).toBe(String(userId)); + expect(payload.Notes).toBe('first'); + expect(payload.Download).toBeGreaterThan(0); + expect(payload.Upload).toBeGreaterThan(0); + + // El primer test aterriza en la DB sin contexto de slot. + const row = await expectMeasurementRow(`notes = 'first'`); + if (row) { + expect(row.upload_failed).toBe(false); + expect(row.scheduled_slot).toBeNull(); + expect(row.scheduled_at).toBeNull(); + expect(row.giga_id_school).toBe(stored.gigaId); + } + + // Nada quedó en la cola offline: el registro se subió en tiempo real. + const pendingQueue = await page.evaluate(async () => { + const openReq = indexedDB.open('connectivity_measurements_db'); + return new Promise((resolve) => { + openReq.onsuccess = () => { + const db = openReq.result; + if (!db.objectStoreNames.contains('measurements')) { + resolve(0); + return; + } + const countReq = db + .transaction('measurements', 'readonly') + .objectStore('measurements') + .count(); + countReq.onsuccess = () => resolve(countReq.result); + countReq.onerror = () => resolve(-1); + }; + openReq.onerror = () => resolve(-1); + }); + }); + expect(pendingQueue).toBe(0); +}); + +test('paso 5: test manual → fila en DB sin contexto de slot', async () => { + // El primer test dispara un modal de felicitación que tapa el medidor. + await dismissModalIfOpen(page); + + // Tras un test completado el medidor circular queda en "TEST AGAIN": es el + // control con el que el usuario dispara un test manual (startNDT('manual')). + const meter = page.locator('div.circular-progress-container:visible').first(); + await expect(meter).toBeVisible(); + + const measurementResponse = page.waitForResponse( + (resp) => + resp.url() === `${API}measurements` && + resp.request().method() === 'POST' && + resp.request().postDataJSON()?.Notes === 'manual', + { timeout: NDT7_UPLOAD_TIMEOUT }, + ); + await meter.click(); + + const upload = await measurementResponse; + expect(upload.ok()).toBe(true); + + // Un test manual no pertenece a ningún slot: sin slot ni hora programada. + const payload = upload.request().postDataJSON(); + expect(payload.Notes).toBe('manual'); + expect(payload.upload_failed).toBe(false); + expect(payload.scheduled_slot).toBeNull(); + expect(payload.scheduled_at).toBeNull(); + expect(payload.Download).toBeGreaterThan(0); + + const row = await expectMeasurementRow(`notes = 'manual'`); + if (row) { + expect(row.upload_failed).toBe(false); + expect(row.scheduled_slot).toBeNull(); + expect(row.scheduled_at).toBeNull(); + } +}); + +test('paso 6: slot programado → fila en DB con slot y scheduled_at, sin reintentos', async () => { + test.setTimeout(SCHEDULER_TICK + NDT7_UPLOAD_TIMEOUT + 60_000); + + // Esperar a que llegue un slot real tardaría horas: se inyecta un semáforo + // vencido para el slot A (choice en el pasado, ventana todavía abierta) y se + // deja que el tick de 60 s del scheduler lo recoja como en producción. + // + // getSemaphore() conserva el semáforo actual si tiene `choice`, el mismo + // intervalType que el que tocaría ahora y un `start` no posterior — de ahí + // que start quede 12 h atrás. scheduledTesting viene desactivado por defecto, + // así que hay que habilitarlo o getSemaphore() lo vacía en cada tick. + const scheduledAt = await page.evaluate(() => { + const now = Date.now(); + const scheduled = now - 90 * 60 * 1000; // hora "originalmente planificada" + const settings = JSON.parse( + localStorage.getItem('savedSettings') || '{}', + ); + settings.scheduledTesting = true; + localStorage.setItem('savedSettings', JSON.stringify(settings)); + localStorage.setItem( + 'scheduleSemaphore', + JSON.stringify({ + start: now - 12 * 60 * 60 * 1000, + end: now + 60 * 60 * 1000, // ventana abierta: el test debe correr + choice: now - 1000, // vencido: dispara en el próximo tick + scheduledAt: scheduled, + slot: 'A', + intervalType: 'daily', + retryAttempts: 0, + backoffLevel: 0, + }), + ); + return scheduled; + }); + + const measurementResponse = page.waitForResponse( + (resp) => + resp.url() === `${API}measurements` && + resp.request().method() === 'POST' && + resp.request().postDataJSON()?.scheduled_slot === 'A', + { timeout: SCHEDULER_TICK + NDT7_UPLOAD_TIMEOUT }, + ); + + const upload = await measurementResponse; + expect(upload.ok()).toBe(true); + + const payload = upload.request().postDataJSON(); + expect(payload.scheduled_slot).toBe('A'); + expect(payload.scheduled_at).toBe(new Date(scheduledAt).toISOString()); + expect(payload.upload_failed).toBe(false); + expect(payload.Download).toBeGreaterThan(0); + + const row = await expectMeasurementRow(`scheduled_slot = 'A'`); + if (row) { + expect(row.upload_failed).toBe(false); + expect(row.scheduled_slot).toBe('A'); + // scheduled_at conserva la hora planificada, no la de ejecución. + expect(row.scheduled_at).toBe(new Date(scheduledAt).toISOString()); + } + + // Camino feliz: el test pasó al primer intento. El scheduler limpia el + // semáforo al tener éxito, así que no quedan reintentos pendientes. + await expect + .poll( + () => + page.evaluate(() => { + const raw = localStorage.getItem('scheduleSemaphore'); + if (!raw) return 0; + return JSON.parse(raw).retryAttempts || 0; + }), + { timeout: 15_000 }, + ) + .toBe(0); +}); + +test('paso 7: la UI refleja las mediciones de los pasos anteriores', async () => { + // Historial local: los tres tests (first, manual, slot) quedaron registrados. + const history = await page.evaluate(() => { + const raw = localStorage.getItem('historicalData'); + return raw ? JSON.parse(raw).measurements.length : 0; + }); + expect(history).toBeGreaterThanOrEqual(3); + + // Y la DB tiene las tres filas para la escuela (solo con stack local). + if (!SKIP_DB) { + const gigaId = await page.evaluate(() => localStorage.getItem('gigaId')); + expect(measurementCount(gigaId!)).toBeGreaterThanOrEqual(3); + } + + // La tarjeta de "última medición" está visible y muestra cifras, no en blanco. + const latest = page.locator('ion-label.latest_measurement:visible').first(); + await expect(latest).toBeVisible(); + const footer = page.locator('ion-card.footer-card:visible').first(); + await expect(footer).toHaveText(/\d/); +}); + +test('paso 8: reinicio → el registro persiste y el scheduler queda armado', async () => { + const before = await page.evaluate(() => ({ + schoolId: localStorage.getItem('schoolId'), + gigaId: localStorage.getItem('gigaId'), + })); + + await page.reload(); + await waitForLoaderGone(page); + + // No vuelve al onboarding: la app arranca directamente en la pantalla de test. + await page.waitForURL('**/starttest', { timeout: 30_000 }); + await expect( + page.locator('div.circular-progress-container:visible').first(), + ).toBeVisible(); + + const after = await page.evaluate(() => ({ + schoolId: localStorage.getItem('schoolId'), + gigaId: localStorage.getItem('gigaId'), + })); + expect(after.schoolId).toBe(before.schoolId); + expect(after.gigaId).toBe(before.gigaId); + + // El scheduler vuelve a armarse solo: tras el reinicio el tick recrea un + // semáforo con su ventana y su hora elegida dentro de ella. + const readSemaphore = () => + page.evaluate(() => { + const raw = localStorage.getItem('scheduleSemaphore'); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed && parsed.choice ? parsed : null; + }); + + await expect + .poll(readSemaphore, { timeout: SCHEDULER_TICK + 30_000 }) + .not.toBeNull(); + + const semaphore = await readSemaphore(); + expect(semaphore.intervalType).toBe('daily'); + expect(semaphore.choice).toBeGreaterThanOrEqual(semaphore.start); + expect(semaphore.choice).toBeLessThanOrEqual(semaphore.end); +}); diff --git a/e2e/seed/e2e-category-config.sql b/e2e/seed/e2e-category-config.sql new file mode 100644 index 00000000..5a3fb21b --- /dev/null +++ b/e2e/seed/e2e-category-config.sql @@ -0,0 +1,23 @@ +-- E2E only: make `giga_meter` the default API category with unrestricted +-- access. The e2e auth mock stamps requests with category giga_meter; this +-- row guarantees that category resolves to an allow-everything config (empty +-- allowedAPIs/notAllowedAPIs) and doubles as the default for any request +-- that reaches the CategoryGuard without a category. DB rows override the +-- static category config (CategoryConfigProvider prefers DB rows). +BEGIN; + +INSERT INTO category_config + (name, "isDefault", "allowedAPIs", "notAllowedAPIs", "responseFilters", + "allowedCountries", swagger, created_at, updated_at) +VALUES + ('giga_meter', true, ARRAY[]::jsonb[], ARRAY[]::jsonb[], '{}'::jsonb, + ARRAY[]::text[], '{"visible": false}'::jsonb, now(), now()) +ON CONFLICT (name) DO UPDATE +SET "isDefault" = true, + "allowedAPIs" = EXCLUDED."allowedAPIs", + "notAllowedAPIs"= EXCLUDED."notAllowedAPIs", + updated_at = now(); + +COMMIT; + +SELECT name, "isDefault" FROM category_config; diff --git a/e2e/seed/seed-spain.sql b/e2e/seed/seed-spain.sql new file mode 100644 index 00000000..afc40a14 --- /dev/null +++ b/e2e/seed/seed-spain.sql @@ -0,0 +1,76 @@ +-- ============================================================ +-- Fixture de España (ES) para la suite e2e — flujo school +-- ============================================================ +-- Portado (y recortado) desde `seed-spain-project-connect.sql` de +-- giga-meter-backend, que vive solo en la línea de `develop`: lo añadió el +-- commit ef07c5b del trabajo health-entity y nunca llegó a `staging`. Esta +-- copia es del repo del app y se monta en el contenedor, para que la suite no +-- dependa de qué rama tenga el backend hermano. +-- +-- Alcance: solo lo que necesita el RC 2.0.4, que registra y mide por +-- `/api/v1` con escuelas. Del original se dejaron fuera `facility_type`, +-- `country_facility_type_whitelist` y `health`: son del trabajo multi-facility +-- (plan 0003), sus tablas no existen en `staging` y el app de esta rama nunca +-- las toca. +-- +-- Lo que necesita el app (trazado desde sus servicios): +-- * dailycheckapp_country → dropdown de países y validación del código +-- * country → destino de la FK de school +-- * school → match por external_id + country_code +-- +-- Registros y mediciones NO se siembran a propósito: crearlos es justamente lo +-- que verifica el test. + +-- ============================================================ +-- 1. country (ES) — destino de la FK de school +-- ============================================================ +INSERT INTO country (name, code, iso3_format, is_active) +VALUES ('Spain', 'ES', 'ESP', true) +ON CONFLICT (code) DO UPDATE +SET name = EXCLUDED.name, + iso3_format = EXCLUDED.iso3_format, + is_active = true; + +-- ============================================================ +-- 2. dailycheckapp_country (ES) — respalda el dropdown de países +-- ============================================================ +-- id 34 / country_id '216' siguen la convención de local-dev-seed.sql. +INSERT INTO dailycheckapp_country (id, code, code_iso3, name, country_id) +VALUES (34, 'ES', 'ESP', 'Spain', '216') +ON CONFLICT (id) DO UPDATE +SET code = EXCLUDED.code, + code_iso3 = EXCLUDED.code_iso3, + name = EXCLUDED.name, + country_id= EXCLUDED.country_id; + +-- ============================================================ +-- 3. school — escuela de prueba +-- ============================================================ +-- Se busca por external_id (case-insensitive) + country_code + is_active +-- + deleted IS NULL. geopoint es geography(Point,4326) de PostGIS: +-- ST_MakePoint(longitud, latitud) — el orden importa. +INSERT INTO school ( + id, external_id, giga_id_school, name, + country_id, country_code, address, + admin_1_name, education_level, + geopoint, is_active, created, modified, deleted +) VALUES ( + 900001, + 'ES-TEST-SCHOOL-01', + '11111111-1111-4111-8111-111111111111', + 'Spain Test School 01', + 216, 'ES', 'Calle de Prueba 1, Madrid', + 'Madrid', 'Primary', + ST_SetSRID(ST_MakePoint(-3.7038, 40.4168), 4326)::geography, + true, NOW(), NOW(), NULL +) +ON CONFLICT (id) DO NOTHING; + +SELECT setval( + pg_get_serial_sequence('school', 'id'), + GREATEST(COALESCE((SELECT MAX(id) FROM school), 0), 900001) +); + +-- Nada de facility_type / whitelist / health a propósito: el RC 2.0.4 solo +-- hace el flujo school sobre /api/v1, y `staging` no tiene esas tablas. Cuando +-- entre multi-facility (plan 0003), el seed de esa rama añade lo suyo. diff --git a/e2e/seed/seed.js b/e2e/seed/seed.js new file mode 100644 index 00000000..3b722fe1 --- /dev/null +++ b/e2e/seed/seed.js @@ -0,0 +1,65 @@ +// Cargador de fixtures para el stack e2e. +// +// Sustituye a `src/prisma/scripts/seed-runner.ts` de giga-meter-backend, que +// vive solo en la línea de `develop` (lo añadió el trabajo health-entity el +// 2026-07-27) y por tanto no existe en `staging`. Al vivir aquí, en el repo del +// app y montado en el contenedor, la suite e2e deja de depender de qué rama +// tenga el backend hermano. +// +// Se ejecuta con el `node` y el `pg` de la imagen del backend (/APP), en el +// entrypoint del compose, entre `prisma migrate deploy` y `start:prod`. +// +// Uso: node seed.js [...] (rutas absolutas o relativas a este dir) +const { readFileSync, existsSync } = require('fs'); +const { resolve, isAbsolute, basename } = require('path'); +const { Client } = require('pg'); + +/** Oculta la contraseña al mostrar el destino de la conexión. */ +function maskDbUrl(url) { + return url.replace(/(:\/\/[^:/@]+:)[^@]*@/, '$1****@'); +} + +function resolveSqlPath(nameOrPath) { + const candidate = isAbsolute(nameOrPath) + ? nameOrPath + : resolve(__dirname, nameOrPath); + if (!existsSync(candidate)) { + throw new Error(`SQL no encontrado: ${nameOrPath} (buscado en ${candidate})`); + } + return candidate; +} + +async function main() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) throw new Error('DATABASE_URL no está definida.'); + + const files = process.argv.slice(2).map(resolveSqlPath); + if (!files.length) throw new Error('No se pasó ningún archivo .sql.'); + + console.log(`→ DB : ${maskDbUrl(databaseUrl)}`); + console.log(`→ Seeds : ${files.map((f) => basename(f)).join(', ')}`); + + const client = new Client({ connectionString: databaseUrl }); + await client.connect(); + + // Los RAISE NOTICE del seed (qué secciones se saltan por no existir la tabla) + // son la señal de si corrió en modo develop o staging: hay que verlos. + client.on('notice', (n) => console.log(` · ${n.message}`)); + + try { + for (const file of files) { + console.log(`\n── Aplicando ${basename(file)} ──────────────────────`); + // node-postgres manda el string completo por simple query, así que + // soporta varias sentencias y bloques DO $$ en un solo query(). + await client.query(readFileSync(file, 'utf8')); + console.log(` OK`); + } + } finally { + await client.end(); + } +} + +main().catch((err) => { + console.error(`\nSeed falló: ${err.message}`); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json index 118f08fa..29a067b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "@babel/runtime": "^7.26.10", "@capacitor/cli": "^7.4.1", "@ionic/angular-toolkit": "^5.0.0", + "@playwright/test": "^1.62.1", "@types/canvas-confetti": "^1.9.0", "@types/jasmine": "~3.6.0", "@types/jasminewd2": "~2.0.3", @@ -11432,6 +11433,22 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.52.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.2.tgz", @@ -26009,6 +26026,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "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/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", diff --git a/package.json b/package.json index 52e1bd91..29ccc0ef 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,11 @@ "build:electron-signed-test": "ng build && npx cap sync @capacitor-community/electron && cd electron && npm run electron:make-signed-no-publish", "test": "ng test", "lint": "ng lint", - "e2e": "ng e2e" + "start:e2e": "ng serve --configuration e2e", + "e2e": "playwright test", + "e2e:headed": "playwright test --headed", + "e2e:stg": "playwright test -c playwright.stg.config.ts", + "e2e:down": "docker compose -f e2e/docker-compose.e2e.yml down -v" }, "private": true, "dependencies": { @@ -73,6 +77,7 @@ "@babel/runtime": "^7.26.10", "@capacitor/cli": "^7.4.1", "@ionic/angular-toolkit": "^5.0.0", + "@playwright/test": "^1.62.1", "@types/canvas-confetti": "^1.9.0", "@types/jasmine": "~3.6.0", "@types/jasminewd2": "~2.0.3", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..28165898 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig } from '@playwright/test'; + +// Happy-path e2e suite (plan 0010 in the workspace project-memory). +// Boots the backend stack in docker (postgres + redis + giga-meter-backend) +// and the Angular app via `ng serve --configuration e2e`, then drives the +// real registration + speed-test flow in a browser. The NDT7 speed test runs +// against real M-Lab servers, so the suite needs internet access and a run +// can take a few minutes. +export default defineConfig({ + testDir: './e2e/playwright', + timeout: 300_000, // registration + real ndt7 test + upload + expect: { timeout: 15_000 }, + workers: 1, // the app is stateful (localStorage/backend rows) — never parallel + retries: 0, + reporter: [ + ['list'], + ['html', { outputFolder: 'e2e/playwright-report', open: 'never' }], + ], + use: { + baseURL: 'http://localhost:4200', + trace: 'retain-on-failure', + video: 'retain-on-failure', + }, + webServer: [ + { + // First run builds the backend image (npm install inside) — allow long. + // /metrics is auth-exempt and only answers once the entrypoint got past + // migrations + seeds, so it doubles as a "fixtures ready" check. + command: 'docker compose -f e2e/docker-compose.e2e.yml up --build', + url: 'http://localhost:3000/metrics', + timeout: 600_000, + reuseExistingServer: true, + stdout: 'ignore', + stderr: 'pipe', + }, + { + command: 'npm run start:e2e', + url: 'http://localhost:4200', + timeout: 300_000, + reuseExistingServer: true, + stdout: 'ignore', + stderr: 'pipe', + }, + ], +}); diff --git a/playwright.stg.config.ts b/playwright.stg.config.ts new file mode 100644 index 00000000..bba002d8 --- /dev/null +++ b/playwright.stg.config.ts @@ -0,0 +1,60 @@ +import { defineConfig } from '@playwright/test'; + +// Variante de la suite e2e contra el **staging real** (Azure), no el stack +// local de docker. Corre con: npm run e2e:stg +// +// Diferencias con playwright.config.ts: +// * No levanta docker: el backend es el desplegado en Azure. +// * Sirve el app con `ng serve` **sin** la configuración e2e, así que usa +// `_environment.prod.ts` tal cual — que está en `mode: 'stg'` y por tanto +// resuelve restAPIStg + tokenStg. El token nunca se maneja aquí. +// * `E2E_SKIP_DB=1`: no hay contenedor de Postgres al que consultar, así que +// las comprobaciones en columna se omiten y quedan las de payload, storage +// y UI. +// * La escuela es una real de staging, cuyo giga id no se conoce de antemano. +// +// AVISO: esto **escribe datos reales** en staging (un registro de dispositivo y +// una medición por cada test que sube). No es un entorno desechable. +// +// Limitación conocida: mientras el PR #349 no esté mergeado y desplegado, +// staging no tiene las columnas del plan 0006, así que esta corrida no puede +// validar `upload_failed`/`scheduled_slot`/`scheduled_at` en base de datos — +// solo que el app los envía en el payload. +const STG_API = 'https://uni-ooi-giga-meter-backend-stg.azurewebsites.net/api/v1/'; + +// Se fijan aquí, no en `webServer.env`, porque eso solo afecta al proceso del +// servidor: los specs las leen en los workers, que heredan el entorno de este +// proceso. Se respeta lo que ya venga del shell para poder sobreescribir. +process.env.E2E_API ??= STG_API; +process.env.E2E_SKIP_DB ??= '1'; +process.env.E2E_SCHOOL_ID ??= 'spaintestschool1'; +// Vacío a propósito: el giga id de una escuela real no se conoce de antemano, +// así que el spec solo comprueba que exista y sea coherente. +process.env.E2E_GIGA_ID ??= ''; + +export default defineConfig({ + testDir: './e2e/playwright', + timeout: 300_000, + expect: { timeout: 20_000 }, // Azure responde más lento que el stack local + workers: 1, + retries: 0, + reporter: [ + ['list'], + ['html', { outputFolder: 'e2e/playwright-report-stg', open: 'never' }], + ], + use: { + baseURL: 'http://localhost:4200', + trace: 'retain-on-failure', + video: 'retain-on-failure', + }, + webServer: [ + { + command: 'npm start', + url: 'http://localhost:4200', + timeout: 300_000, + reuseExistingServer: false, // no reutilizar un ng serve apuntando a local + stdout: 'ignore', + stderr: 'pipe', + }, + ], +}); diff --git a/src/environments/_environment.e2e.ts b/src/environments/_environment.e2e.ts new file mode 100644 index 00000000..229df1eb --- /dev/null +++ b/src/environments/_environment.e2e.ts @@ -0,0 +1,21 @@ +// E2E environment — replaces the gitignored `_environment.prod.ts` when the app +// is served with `ng serve --configuration e2e` (see angular.json fileReplacements). +// Points every mode at the local docker backend started by +// `e2e/docker-compose.e2e.yml`, which validates tokens against a local mock +// that accepts anything — so the token values are placeholders. +export const environment = { + production: false, + mode: 'dev', + + restAPI: 'http://localhost:3000/api/v1/', + token: 'e2e-local-token', + + restAPIDev: 'http://localhost:3000/api/v1/', + tokenDev: 'e2e-local-token', + + restAPIStg: 'http://localhost:3000/api/v1/', + tokenStg: 'e2e-local-token', + + // The Playwright suite intercepts api.ipinfo.io, so this token is never used. + ipInfoToken: 'e2e-local-token', +}; From 9b41185fea0bfcf7788be616e25ecaac2f17e7f3 Mon Sep 17 00:00:00 2001 From: vipulbhavsar94 Date: Mon, 17 Aug 2026 22:41:24 +0200 Subject: [PATCH 12/22] Integrate PostHog analytics (renderer + main) with school-group identity --- electron/package-lock.json | 44 +++- electron/package.json | 1 + electron/src/analytics.ts | 144 ++++++++++++ electron/src/index.ts | 35 +++ package-lock.json | 117 ++++++++++ package.json | 1 + src/app/app.component.ts | 7 + src/app/confirmschool/confirmschool.page.ts | 5 +- src/app/home/home.page.ts | 5 +- src/app/services/posthog.service.ts | 207 ++++++++++++++++++ src/environments/_environment.prod.ts.example | 5 + src/environments/environment.ts | 4 + 12 files changed, 570 insertions(+), 5 deletions(-) create mode 100644 electron/src/analytics.ts create mode 100644 src/app/services/posthog.service.ts diff --git a/electron/package-lock.json b/electron/package-lock.json index d97727c5..9d508647 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "unicef-pdca", - "version": "2.0.2", + "version": "2.0.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "unicef-pdca", - "version": "2.0.2", + "version": "2.0.3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -20,7 +20,8 @@ "electron-updater": "~4.3.9", "electron-window-state": "^5.0.3", "fs-extra": "^10.0.1", - "systeminformation": "^5.27.11" + "posthog-node": "^5.21.2", + "systeminformation": "^5.27.8" }, "devDependencies": { "electron": "^29.4.6", @@ -407,6 +408,15 @@ "node": ">=10" } }, + "node_modules/@posthog/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.10.0.tgz", + "integrity": "sha512-Xk3JQ+cdychsvftrV3G9ZrN9W329lbyFW0pGJXFGKFQf8qr4upw2SgNg9BVorjSrfhoXZRnJGt/uNF4nGFBL5A==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + } + }, "node_modules/@sentry/cli": { "version": "2.55.0", "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.55.0.tgz", @@ -4597,6 +4607,18 @@ "node": ">=10.4.0" } }, + "node_modules/posthog-node": { + "version": "5.21.2", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.21.2.tgz", + "integrity": "sha512-Jehlu0KguL1LLyUczCt86OtA5INmeStK3zcgbv1BSyMcNxs0HP3GQogBrYhwhqHsk6JopiFFVpJyZEoXOUMhGw==", + "license": "MIT", + "dependencies": { + "@posthog/core": "1.10.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/prepend-http": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", @@ -6219,6 +6241,14 @@ } } }, + "@posthog/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.10.0.tgz", + "integrity": "sha512-Xk3JQ+cdychsvftrV3G9ZrN9W329lbyFW0pGJXFGKFQf8qr4upw2SgNg9BVorjSrfhoXZRnJGt/uNF4nGFBL5A==", + "requires": { + "cross-spawn": "^7.0.6" + } + }, "@sentry/cli": { "version": "2.55.0", "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.55.0.tgz", @@ -9380,6 +9410,14 @@ "xmlbuilder": "^15.1.1" } }, + "posthog-node": { + "version": "5.21.2", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.21.2.tgz", + "integrity": "sha512-Jehlu0KguL1LLyUczCt86OtA5INmeStK3zcgbv1BSyMcNxs0HP3GQogBrYhwhqHsk6JopiFFVpJyZEoXOUMhGw==", + "requires": { + "@posthog/core": "1.10.0" + } + }, "prepend-http": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", diff --git a/electron/package.json b/electron/package.json index a38f6137..85356cb8 100644 --- a/electron/package.json +++ b/electron/package.json @@ -35,6 +35,7 @@ "electron-updater": "~4.3.9", "electron-window-state": "^5.0.3", "fs-extra": "^10.0.1", + "posthog-node": "^5.21.2", "systeminformation": "^5.27.8" }, "devDependencies": { diff --git a/electron/src/analytics.ts b/electron/src/analytics.ts new file mode 100644 index 00000000..c63deae3 --- /dev/null +++ b/electron/src/analytics.ts @@ -0,0 +1,144 @@ +import { app } from 'electron'; +import { PostHog } from 'posthog-node'; + +/** + * PostHog (product analytics) for the Electron MAIN process. + * + * Server-side counterpart to the renderer's posthog-js + * (src/app/services/posthog.service.ts). It captures app-level telemetry the + * UI cannot see — launch, auto-update outcomes, quit. + * + * Identity model: + * - The main process has no persistence of its own, so it does NOT invent a + * device id (and deliberately does NOT use the hardware ID, which can be + * null or collide). Instead the renderer relays its own PostHog anonymous + * distinct_id (and the school's GigaID) over IPC; we adopt both so main and + * renderer events are the same person and roll up under the same school. + * - Events fired before that relay arrives (e.g. app_launched at startup) are + * buffered and flushed once identity is known. + * + * Config comes from environment variables so no secret is committed: + * POSTHOG_API_KEY the project API key (phc_...) + * POSTHOG_HOST optional, defaults to PostHog US Cloud + * + * Every operation is wrapped in try/catch so analytics can never crash the app. + */ +const SCHOOL_GROUP = 'school'; + +// PostHog project API key (phc_...) is a PUBLISHABLE client key — it is meant +// to ship in clients, so baking it in is safe. POSTHOG_API_KEY env var overrides. +const DEFAULT_POSTHOG_API_KEY = 'phc_y8Km5qP2Jx3znSMppNz4NHUShqfnNDFhK5Pf8tFnZh5T'; + +interface QueuedEvent { + event: string; + properties?: Record; +} + +let client: PostHog | null = null; +let distinctId: string | null = null; +let schoolGigaId: string | null = null; +const queue: QueuedEvent[] = []; + +export function initPosthog(): void { + try { + const apiKey = process.env.POSTHOG_API_KEY || DEFAULT_POSTHOG_API_KEY; + const host = process.env.POSTHOG_HOST || 'https://us.i.posthog.com'; + + if (!apiKey) { + console.warn('[PostHog][main] Skipping init: no API key.'); + return; + } + + // Desktop app can quit shortly after an event; flush each event + // immediately rather than batching. + client = new PostHog(apiKey, { + host, + flushAt: 1, + flushInterval: 0, + }); + console.log('[PostHog][main] initialized.'); + } catch (error) { + console.warn('[PostHog][main] init failed:', error); + } +} + +/** + * Adopt the identity relayed from the renderer (its anonymous distinct_id and + * the school GigaID) and flush any events buffered before it arrived. + */ +export function setPosthogIdentity( + id: string | null | undefined, + gigaId: string | number | null | undefined +): void { + try { + if (id) { + distinctId = id; + } + if (gigaId != null && gigaId !== '') { + schoolGigaId = String(gigaId); + } + if (distinctId) { + flushQueue(); + } + } catch (error) { + console.warn('[PostHog][main] setPosthogIdentity failed:', error); + } +} + +export function capturePosthog( + event: string, + properties?: Record +): void { + try { + if (!client) { + return; + } + if (!distinctId) { + // Identity not relayed yet — buffer until the renderer reports in. + queue.push({ event, properties }); + return; + } + sendEvent(event, properties); + } catch (error) { + console.warn('[PostHog][main] capture failed:', error); + } +} + +function sendEvent(event: string, properties?: Record): void { + if (!client || !distinctId) { + return; + } + client.capture({ + distinctId, + event, + properties: { + ...properties, + app_version: app.getVersion(), + platform: process.platform, + source: 'electron-main', + ...(schoolGigaId ? { giga_id_school: schoolGigaId } : {}), + }, + ...(schoolGigaId ? { groups: { [SCHOOL_GROUP]: schoolGigaId } } : {}), + }); +} + +function flushQueue(): void { + while (queue.length > 0) { + const item = queue.shift(); + if (item) { + sendEvent(item.event, item.properties); + } + } +} + +/** Flush and close the client. Call on app quit. */ +export async function shutdownPosthog(): Promise { + try { + if (client) { + await client.shutdown(); + client = null; + } + } catch (error) { + console.warn('[PostHog][main] shutdown failed:', error); + } +} diff --git a/electron/src/index.ts b/electron/src/index.ts index 2151b13c..9327a5d8 100644 --- a/electron/src/index.ts +++ b/electron/src/index.ts @@ -21,11 +21,20 @@ import { getIsQuiting, } from './setup'; import { captureException } from '@sentry/node'; +import { + initPosthog, + setPosthogIdentity, + capturePosthog, + shutdownPosthog, +} from './analytics'; // Set userData path to use name instead of productName - must be set before app is ready const userDataPath = path.join(app.getPath('appData'), 'unicef-pdca'); app.setPath('userData', userDataPath); +// Initialize main-process product analytics (renderer uses posthog-js). +initPosthog(); + const gotTheLock = app.requestSingleInstanceLock(); // Graceful handling of unhandled errors. unhandled({ @@ -135,6 +144,17 @@ if (!gotTheLock) { systemData.uuid || systemData.serial || 'NO_UUID_AVAILABLE'; console.log('\n🔑 PRIMARY HARDWARE ID (use this):', hardwareId); + // Note: hardwareId is recorded only as diagnostic metadata, NOT as the + // analytics identity. The real distinct_id + school group are relayed + // from the renderer over the 'posthog-identity' IPC channel; this event + // is buffered until that arrives. + capturePosthog('app_launched', { + manufacturer: systemData.manufacturer, + model: systemData.model, + os: osData.distro, + hardware_id: hardwareId, + }); + // Send hardware ID to renderer process when ready if (mainWindow && mainWindow.webContents) { const hardwareData = { @@ -208,6 +228,7 @@ if (!gotTheLock) { }, 3600000); autoUpdater.on('update-downloaded', (_event, releaseNotes, releaseName) => { + capturePosthog('app_update_downloaded', { release: releaseName }); const dialogOpts = { type: 'info' as const, buttons: ['Restart / Reinicie. / Перезапуск', 'Later / Después / Позже'], @@ -269,6 +290,7 @@ if (!gotTheLock) { autoUpdater.on('error', (error) => { console.error('Update Error:', error); captureException(error); + capturePosthog('app_update_error', { message: error?.message }); }); /* autoUpdater.on('error', (error) => { @@ -325,6 +347,9 @@ app.on('activate', async function () { app.on('before-quit', () => { setIsQuiting(true); myCapacitorApp.cleanup(); + capturePosthog('app_quit'); + // Fire-and-forget flush; events use flushAt:1 so they are already sent. + void shutdownPosthog(); }); // Place all ipc or other electron api calls and custom functionality under this line @@ -333,6 +358,16 @@ ipcMain.addListener('closeFromUi', (ev) => { myCapacitorApp.getMainWindow().hide(); }); +// Receive the renderer's PostHog identity (anonymous distinct_id + school +// GigaID) so main-process analytics share the same person and school group. +ipcMain.on('posthog-identity', (_ev, payload) => { + try { + setPosthogIdentity(payload?.distinctId, payload?.gigaId); + } catch (error) { + console.error('❌ [Electron] Error applying PostHog identity:', error); + } +}); + // IPC handler to get Windows username from renderer process ipcMain.handle('get-windows-username', async () => { try { diff --git a/package-lock.json b/package-lock.json index 118f08fa..08b82f9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "fs-extra": "^10.0.1", "idb": "^8.0.2", "ngx-pipes": "^3.2.2", + "posthog-js": "^1.417.3", "rxjs": "~7.8.0", "systeminformation": "^5.27.8", "tslib": "^2.2.0", @@ -11432,6 +11433,31 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@posthog/browser-common": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.5.0.tgz", + "integrity": "sha512-8DaxVZS1bQPbA514RePurLNbYjei3P4jhnC206DwVv5XThmZM3QdlsXenI2ujE3pLbgQ79hYn9o1Kda8I3WK/Q==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.47.0", + "@posthog/types": "^1.402.2" + } + }, + "node_modules/@posthog/core": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.2.tgz", + "integrity": "sha512-zZNvsEg+YCezvJKeWdaZ77ngjKJvGnAEo31DIy2guYeDtZ5kNjJ4c6CkwqtmXmwNXjzOFGJyshzv3Fv2gHVJYA==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.404.1" + } + }, + "node_modules/@posthog/types": { + "version": "1.404.1", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.404.1.tgz", + "integrity": "sha512-i2Gei6ARfOSBeTN4s2yUP1p97s2UNI+1NWmtLjhnR/V6t3RFOfI1sBWKcJNWHjtoOCCWoAFU+PNPY6SgT2VtEQ==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.52.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.2.tgz", @@ -12488,6 +12514,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -17026,6 +17059,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", @@ -19205,6 +19247,12 @@ } } }, + "node_modules/fflate": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", + "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", + "license": "MIT" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -28325,6 +28373,56 @@ "node": ">=6.14.4" } }, + "node_modules/posthog-js": { + "version": "1.417.3", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.417.3.tgz", + "integrity": "sha512-F+73VV31nNCyHmDSB5IycWhQ9qhWIwC81GIS2mXz1m0VfWwvGIVu56OKxH2ZbkiXsD5VgE032cC/5ZzuAX0pwg==", + "license": "(Apache-2.0 AND MIT)", + "dependencies": { + "@posthog/browser-common": "^0.5.0", + "@posthog/core": "^1.48.2", + "@posthog/types": "^1.404.1", + "core-js": "^3.49.0", + "dompurify": "^3.4.13", + "fflate": "^0.4.8", + "preact": "^10.29.3", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^5.3.0", + "web-vitals-soft-navs": "npm:web-vitals@6.0.0" + } + }, + "node_modules/posthog-js/node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -28783,6 +28881,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -33096,6 +33200,19 @@ "license": "MIT", "optional": true }, + "node_modules/web-vitals": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz", + "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==", + "license": "Apache-2.0" + }, + "node_modules/web-vitals-soft-navs": { + "name": "web-vitals", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.0.0.tgz", + "integrity": "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA==", + "license": "Apache-2.0" + }, "node_modules/webdriver-js-extender": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/webdriver-js-extender/-/webdriver-js-extender-2.1.0.tgz", diff --git a/package.json b/package.json index 52e1bd91..8c9641fb 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "fs-extra": "^10.0.1", "idb": "^8.0.2", "ngx-pipes": "^3.2.2", + "posthog-js": "^1.417.3", "rxjs": "~7.8.0", "systeminformation": "^5.27.8", "tslib": "^2.2.0", diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 9ee9d991..3e496fcf 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -17,6 +17,7 @@ import { LogoutModalComponent } from './components/logout-modal/logout-modal.com import { HardwareIdService } from './services/hardware-id.service'; import { SchoolService } from './services/school.service'; import { MatomoService } from './services/matomo.service'; +import { PosthogService } from './services/posthog.service'; // const shell = require('electron').shell; @Component({ @@ -73,12 +74,18 @@ export class AppComponent { private router: Router, private schoolService: SchoolService, private matomoService: MatomoService, + private posthogService: PosthogService, ) { try { this.matomoService.init(); } catch (error) { console.warn('Matomo init failed:', error); } + try { + this.posthogService.init(); + } catch (error) { + console.warn('PostHog init failed:', error); + } this.filteredOptions = []; this.selectedLanguage = this.settingsService.get('applicationLanguage')?.code ?? diff --git a/src/app/confirmschool/confirmschool.page.ts b/src/app/confirmschool/confirmschool.page.ts index b2bf6e70..d104d451 100644 --- a/src/app/confirmschool/confirmschool.page.ts +++ b/src/app/confirmschool/confirmschool.page.ts @@ -17,6 +17,7 @@ import { SharedService } from '../services/shared-service.service'; import { TranslateService } from '@ngx-translate/core'; import { HardwareIdService } from '../services/hardware-id.service'; import { LocationService } from '../services/location.service'; +import { PosthogService } from '../services/posthog.service'; @Component({ selector: 'app-confirmschool', templateUrl: 'confirmschool.page.html', @@ -46,7 +47,8 @@ export class ConfirmschoolPage implements OnInit{ private translate: TranslateService, private sharedService: SharedService, private hardwareIdService: HardwareIdService, - private locationService: LocationService + private locationService: LocationService, + private posthogService: PosthogService ) { const appLang = this.settings.get('applicationLanguage'); this.translate.use(appLang.code); @@ -136,6 +138,7 @@ export class ConfirmschoolPage implements OnInit{ this.storage.set('schoolUserId', response); this.storage.set('schoolId', this.schoolId); this.storage.set('gigaId', this.school.giga_id_school); + this.posthogService.setSchool(this.school.giga_id_school); this.storage.set('ip_address', c?.ip); this.storage.set('version', environment.app_version); //this.storage.set('country_code', c.country); diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 55eba8d0..28a48ae8 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -9,6 +9,7 @@ import { StorageService } from '../services/storage.service'; import { checkRightGigaId, removeUnregisterSchool } from './home.utils'; import { environment } from '../../environments/environment'; import { HardwareIdService } from '../services/hardware-id.service'; +import { PosthogService } from '../services/posthog.service'; @Component({ selector: 'app-home', @@ -30,7 +31,8 @@ export class HomePage { private storage: StorageService, private loading: LoadingService, private readonly schoolService: SchoolService, - private hardwareIdService: HardwareIdService + private hardwareIdService: HardwareIdService, + private posthogService: PosthogService ) { translate.setDefaultLang('en'); const applicationLanguage = this.settingsService.get('applicationLanguage'); @@ -278,6 +280,7 @@ export class HomePage { if (registrationData.giga_id_school != null) { await this.storage.set('gigaId', registrationData.giga_id_school); console.log(' ✓ Set gigaId:', registrationData.giga_id_school); + this.posthogService.setSchool(registrationData.giga_id_school); } if (registrationData.mac_address != null) { await this.storage.set('macAddress', registrationData.mac_address); diff --git a/src/app/services/posthog.service.ts b/src/app/services/posthog.service.ts new file mode 100644 index 00000000..7a9db006 --- /dev/null +++ b/src/app/services/posthog.service.ts @@ -0,0 +1,207 @@ +import { Injectable } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs/operators'; +import posthog from 'posthog-js'; +import { environment } from 'src/environments/environment'; +import { StorageService } from './storage.service'; + +/** + * Lightweight PostHog (product analytics) integration for the Angular + * renderer. Runs alongside Matomo — see MatomoService. + * + * Identity model (see also electron/src/analytics.ts): + * - The PERSON (distinct_id) is PostHog's own persistent anonymous UUID. + * We deliberately do NOT identify by hardware ID — it can be null or, in + * rare cases, collide across machines. PostHog's anon UUID is per-install, + * stable (localStorage), and never null. + * - The SCHOOL (GigaID / giga_id_school) is attached as a PostHog "group" + * plus an event property, so every device at a school rolls up under one + * school while individual devices stay distinct. + * - The renderer's distinct_id and school are relayed to the Electron main + * process over IPC so main-process telemetry (posthog-node) shares the + * same person and group. + * + * All operations are wrapped in try/catch so any failure here can never + * break the app. + */ +@Injectable({ + providedIn: 'root', +}) +export class PosthogService { + private initialized = false; + + // PostHog group type used for school-level rollups. + private readonly SCHOOL_GROUP = 'school'; + + // Virtual host used in Electron so PostHog receives clean URLs + // (e.g. https://app.gigameter.local/home) instead of file:// paths. + private readonly electronVirtualOrigin = 'https://app.gigameter.local'; + + constructor(private router: Router, private storage: StorageService) {} + + /** + * Initialize PostHog tracking. Safe to call multiple times. + * Silently no-ops if configuration is missing. + */ + init(): void { + try { + if (this.initialized) { + return; + } + + const apiKey = environment.posthog?.apiKey; + const host = environment.posthog?.host; + + if (!apiKey || apiKey === 'POSTHOG_PROJECT_API_KEY' || !host) { + console.warn('[PostHog] Skipping init: missing API key or host.'); + return; + } + + posthog.init(apiKey, { + api_host: host, + // We send events manually; autocapture is noisy and pathname-based + // (file:// in Electron), so it is disabled. + autocapture: false, + capture_pageview: false, + capture_pageleave: true, + // Desktop app: localStorage persistence keeps a stable anon UUID. + persistence: 'localStorage+cookie', + disable_session_recording: true, + loaded: () => { + // Attach the school group if this device is already registered, + // and hand our identity to the Electron main process. + this.applySchoolFromStorage(); + this.relayIdentityToMain(); + }, + }); + + posthog.register({ + app: environment.appName, + app_version: environment.app_version, + is_electron: !!environment.isElectron, + }); + + // Report the first page view with a clean URL. + this.capturePageView(); + this.trackRouteChanges(); + + this.initialized = true; + } catch (error) { + console.warn('[PostHog] init failed:', error); + } + } + + /** + * Capture a custom event. Safe to call even if PostHog is not initialized. + */ + capture(event: string, properties?: Record): void { + try { + if (!this.initialized) { + return; + } + posthog.capture(event, properties); + } catch (error) { + console.warn('[PostHog] capture failed:', error); + } + } + + /** + * Manually capture a page view with a clean URL. + */ + capturePageView(url?: string): void { + try { + if (!this.initialized) { + return; + } + const path = url || window.location.pathname || '/'; + posthog.capture('$pageview', { + $current_url: this.getTrackingOrigin() + path, + }); + } catch (error) { + console.warn('[PostHog] capturePageView failed:', error); + } + } + + /** + * Associate this device (and future events) with a school group. Call this + * after registration completes / GigaID becomes known. Safe to call with a + * missing value (no-ops). + */ + setSchool(gigaId: string | number | null | undefined): void { + try { + if (!this.initialized || gigaId == null || gigaId === '') { + return; + } + const key = String(gigaId); + posthog.group(this.SCHOOL_GROUP, key, { giga_id_school: key }); + this.relayIdentityToMain(); + } catch (error) { + console.warn('[PostHog] setSchool failed:', error); + } + } + + private applySchoolFromStorage(): void { + try { + const gigaId = this.storage.get('gigaId'); + if (gigaId) { + posthog.group(this.SCHOOL_GROUP, String(gigaId), { + giga_id_school: String(gigaId), + }); + } + } catch (error) { + console.warn('[PostHog] applySchoolFromStorage failed:', error); + } + } + + /** + * Hand the renderer's distinct_id and current school to the Electron main + * process so main-process events (posthog-node) share the same person/group. + */ + private relayIdentityToMain(): void { + try { + const ipc = (window as any).ipcRenderer; + if (!environment.isElectron || !ipc?.send) { + return; + } + ipc.send('posthog-identity', { + distinctId: posthog.get_distinct_id(), + gigaId: this.storage.get('gigaId') || null, + }); + } catch (error) { + console.warn('[PostHog] relayIdentityToMain failed:', error); + } + } + + private trackRouteChanges(): void { + try { + this.router.events + .pipe(filter((event) => event instanceof NavigationEnd)) + .subscribe((event: NavigationEnd) => { + try { + const url = event.urlAfterRedirects || event.url; + this.capturePageView(url); + } catch (error) { + console.warn('[PostHog] route tracking failed:', error); + } + }); + } catch (error) { + console.warn('[PostHog] trackRouteChanges setup failed:', error); + } + } + + /** + * Returns a clean origin for tracking. In Electron the real origin is + * file://, which is useless for analytics, so we substitute a fixed + * virtual host. + */ + private getTrackingOrigin(): string { + try { + if (environment.isElectron) { + return this.electronVirtualOrigin; + } + return window.location.origin; + } catch { + return this.electronVirtualOrigin; + } + } +} diff --git a/src/environments/_environment.prod.ts.example b/src/environments/_environment.prod.ts.example index 3f902faf..796a3940 100644 --- a/src/environments/_environment.prod.ts.example +++ b/src/environments/_environment.prod.ts.example @@ -20,6 +20,11 @@ export const environment = { matomoSiteIdDev: 'MATOMO_SITE_ID_DEV', matomoSiteIdStg: 'MATOMO_SITE_ID_STG', matomoSiteIdProd: 'MATOMO_SITE_ID_PROD', + + // PostHog analytics (Cloud). Same key is shared by the Angular renderer + // (posthog-js) and the Electron main process (posthog-node). + posthogApiKey: 'POSTHOG_PROJECT_API_KEY', // "Project API Key" (phc_...) + posthogHost: 'https://us.i.posthog.com', // or https://eu.i.posthog.com languages: [ { name: 'En', diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 5bc87e80..6e67903c 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -28,6 +28,10 @@ export const environment = { trackerUrl: (env as any).matomoTrackerUrl as string, siteId: environmentConfig[env.mode].matomoSiteId as string, }, + posthog: { + apiKey: (env as any).posthogApiKey as string, + host: ((env as any).posthogHost as string) || 'https://us.i.posthog.com', + }, app_version: '2.0.3', appName: 'Giga Meter', appNameSuffix: '', From 06ab9808bd91e1e8291074ce78ca3196f63b028c Mon Sep 17 00:00:00 2001 From: vipulbhavsar94 Date: Mon, 17 Aug 2026 22:48:53 +0200 Subject: [PATCH 13/22] Use PostHog EU region host --- electron/src/analytics.ts | 2 +- src/environments/_environment.prod.ts.example | 2 +- src/environments/environment.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/electron/src/analytics.ts b/electron/src/analytics.ts index c63deae3..769d7e74 100644 --- a/electron/src/analytics.ts +++ b/electron/src/analytics.ts @@ -42,7 +42,7 @@ const queue: QueuedEvent[] = []; export function initPosthog(): void { try { const apiKey = process.env.POSTHOG_API_KEY || DEFAULT_POSTHOG_API_KEY; - const host = process.env.POSTHOG_HOST || 'https://us.i.posthog.com'; + const host = process.env.POSTHOG_HOST || 'https://eu.i.posthog.com'; if (!apiKey) { console.warn('[PostHog][main] Skipping init: no API key.'); diff --git a/src/environments/_environment.prod.ts.example b/src/environments/_environment.prod.ts.example index 796a3940..2a9e3183 100644 --- a/src/environments/_environment.prod.ts.example +++ b/src/environments/_environment.prod.ts.example @@ -24,7 +24,7 @@ export const environment = { // PostHog analytics (Cloud). Same key is shared by the Angular renderer // (posthog-js) and the Electron main process (posthog-node). posthogApiKey: 'POSTHOG_PROJECT_API_KEY', // "Project API Key" (phc_...) - posthogHost: 'https://us.i.posthog.com', // or https://eu.i.posthog.com + posthogHost: 'https://eu.i.posthog.com', // EU region (or https://us.i.posthog.com) languages: [ { name: 'En', diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 6e67903c..28911a97 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -30,7 +30,7 @@ export const environment = { }, posthog: { apiKey: (env as any).posthogApiKey as string, - host: ((env as any).posthogHost as string) || 'https://us.i.posthog.com', + host: ((env as any).posthogHost as string) || 'https://eu.i.posthog.com', }, app_version: '2.0.3', appName: 'Giga Meter', From f63ce71a8c7158be631c74fe8e02ffd392ca0000 Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Tue, 18 Aug 2026 01:08:08 +0200 Subject: [PATCH 14/22] feat: PostHog product analytics in the app client (v2.0.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 2 of the v2.0.4 release plan. Mirrors matomo.service.ts: everything is wrapped in try/catch so a failure here can never take the app down, and with no configuration the service simply does nothing. posthog-js is bundled rather than loaded from a CDN the way Matomo is. This is a desktop app that spends real time offline — that is literally what it measures — so the SDK needs to queue events until connectivity returns, and bundling avoids pulling a remote script in under Electron's CSP. Configuration (placeholders for now, real keys still to be pasted in): - posthogKeyDev/Stg/Prod + posthogHost in _environment.prod.ts, with placeholders in the .example. The project API key is the Sentry-DSN equivalent: public and embedded in the build, never a personal API key. - An empty key disables PostHog for that mode, so an unconfigured build sends nothing. That is also what keeps the e2e suite from talking to a real project. Privacy choices worth flagging: - Identity is the school's giga id — the same identifier already travelling with every measurement. No school name, Windows username, install path or IP. - autocapture and capture_pageview are off; only explicit events are sent. Pageviews are captured manually because autocapture does not see hash routes. - Session replay is off behind a flag. It records the user's screen and the scope is still being defined by Shilpa's research; in schools that is a privacy call, not a technical one. Funnel events: app_started, registration_completed, measurement_uploaded, measurement_queued_offline, measurements_synced, measurements_sync_failed. The last three close the loop on the plan 0006 offline flag. Verified: tsc clean, build OK, karma shows the same 18 failures / 40 passes with and without this change (pre-existing baseline, no regressions), and the Playwright e2e suite stays green (5 passed). Plan: project-memory/plans/0004-release-v2.0.4-2026-08.md (giga repo) Co-Authored-By: Claude Opus 5 --- package-lock.json | 117 +++++++++++ package.json | 1 + src/app/app.component.ts | 8 + src/app/confirmschool/confirmschool.page.ts | 12 +- src/app/services/posthog.service.ts | 187 ++++++++++++++++++ src/app/services/sync.service.ts | 13 +- src/app/services/upload.service.ts | 23 ++- src/environments/_environment.e2e.ts | 8 + src/environments/_environment.prod.ts.example | 13 ++ src/environments/environment.ts | 14 ++ 10 files changed, 392 insertions(+), 4 deletions(-) create mode 100644 src/app/services/posthog.service.ts diff --git a/package-lock.json b/package-lock.json index 29a067b5..94928188 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "fs-extra": "^10.0.1", "idb": "^8.0.2", "ngx-pipes": "^3.2.2", + "posthog-js": "^1.417.4", "rxjs": "~7.8.0", "systeminformation": "^5.27.8", "tslib": "^2.2.0", @@ -11449,6 +11450,31 @@ "node": ">=20" } }, + "node_modules/@posthog/browser-common": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.5.0.tgz", + "integrity": "sha512-8DaxVZS1bQPbA514RePurLNbYjei3P4jhnC206DwVv5XThmZM3QdlsXenI2ujE3pLbgQ79hYn9o1Kda8I3WK/Q==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.47.0", + "@posthog/types": "^1.402.2" + } + }, + "node_modules/@posthog/core": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.2.tgz", + "integrity": "sha512-zZNvsEg+YCezvJKeWdaZ77ngjKJvGnAEo31DIy2guYeDtZ5kNjJ4c6CkwqtmXmwNXjzOFGJyshzv3Fv2gHVJYA==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.404.1" + } + }, + "node_modules/@posthog/types": { + "version": "1.404.1", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.404.1.tgz", + "integrity": "sha512-i2Gei6ARfOSBeTN4s2yUP1p97s2UNI+1NWmtLjhnR/V6t3RFOfI1sBWKcJNWHjtoOCCWoAFU+PNPY6SgT2VtEQ==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.52.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.2.tgz", @@ -12505,6 +12531,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -17043,6 +17076,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", @@ -19222,6 +19264,12 @@ } } }, + "node_modules/fflate": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", + "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", + "license": "MIT" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -28389,6 +28437,56 @@ "node": ">=6.14.4" } }, + "node_modules/posthog-js": { + "version": "1.417.4", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.417.4.tgz", + "integrity": "sha512-7hJ8k66qQ9+C390ahfXwna2dljkEk+l0Ks6lYhMlhrMLtG4Q02qLe3GCJE7IU8zyd08aky2p9mAqDliu2yuIqQ==", + "license": "(Apache-2.0 AND MIT)", + "dependencies": { + "@posthog/browser-common": "^0.5.0", + "@posthog/core": "^1.48.2", + "@posthog/types": "^1.404.1", + "core-js": "^3.49.0", + "dompurify": "^3.4.13", + "fflate": "^0.4.8", + "preact": "^10.29.3", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^5.3.0", + "web-vitals-soft-navs": "npm:web-vitals@6.0.0" + } + }, + "node_modules/posthog-js/node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -28847,6 +28945,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -33160,6 +33264,19 @@ "license": "MIT", "optional": true }, + "node_modules/web-vitals": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz", + "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==", + "license": "Apache-2.0" + }, + "node_modules/web-vitals-soft-navs": { + "name": "web-vitals", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.0.0.tgz", + "integrity": "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA==", + "license": "Apache-2.0" + }, "node_modules/webdriver-js-extender": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/webdriver-js-extender/-/webdriver-js-extender-2.1.0.tgz", diff --git a/package.json b/package.json index 29ccc0ef..af378192 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "fs-extra": "^10.0.1", "idb": "^8.0.2", "ngx-pipes": "^3.2.2", + "posthog-js": "^1.417.4", "rxjs": "~7.8.0", "systeminformation": "^5.27.8", "tslib": "^2.2.0", diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 9ee9d991..dee43b1c 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -17,6 +17,7 @@ import { LogoutModalComponent } from './components/logout-modal/logout-modal.com import { HardwareIdService } from './services/hardware-id.service'; import { SchoolService } from './services/school.service'; import { MatomoService } from './services/matomo.service'; +import { PosthogService } from './services/posthog.service'; // const shell = require('electron').shell; @Component({ @@ -73,12 +74,19 @@ export class AppComponent { private router: Router, private schoolService: SchoolService, private matomoService: MatomoService, + private posthogService: PosthogService, ) { try { this.matomoService.init(); } catch (error) { console.warn('Matomo init failed:', error); } + try { + this.posthogService.init(); + this.posthogService.capture('app_started'); + } catch (error) { + console.warn('PostHog init failed:', error); + } this.filteredOptions = []; this.selectedLanguage = this.settingsService.get('applicationLanguage')?.code ?? diff --git a/src/app/confirmschool/confirmschool.page.ts b/src/app/confirmschool/confirmschool.page.ts index b2bf6e70..2f99c921 100644 --- a/src/app/confirmschool/confirmschool.page.ts +++ b/src/app/confirmschool/confirmschool.page.ts @@ -17,6 +17,7 @@ import { SharedService } from '../services/shared-service.service'; import { TranslateService } from '@ngx-translate/core'; import { HardwareIdService } from '../services/hardware-id.service'; import { LocationService } from '../services/location.service'; +import { PosthogService } from '../services/posthog.service'; @Component({ selector: 'app-confirmschool', templateUrl: 'confirmschool.page.html', @@ -46,7 +47,8 @@ export class ConfirmschoolPage implements OnInit{ private translate: TranslateService, private sharedService: SharedService, private hardwareIdService: HardwareIdService, - private locationService: LocationService + private locationService: LocationService, + private posthog: PosthogService ) { const appLang = this.settings.get('applicationLanguage'); this.translate.use(appLang.code); @@ -147,6 +149,14 @@ export class ConfirmschoolPage implements OnInit{ this.storage.setFirstTimeVisit(true); this.storage.setRegistrationCompleted(Date.now()); + // A partir de aquí los eventos pertenecen a esta escuela. + this.posthog.identify(this.school.giga_id_school, { + country_code: this.selectedCountry, + }); + this.posthog.capture('registration_completed', { + country_code: this.selectedCountry, + }); + this.loading.dismiss(); // Navigate to starttest page normally diff --git a/src/app/services/posthog.service.ts b/src/app/services/posthog.service.ts new file mode 100644 index 00000000..73b530a5 --- /dev/null +++ b/src/app/services/posthog.service.ts @@ -0,0 +1,187 @@ +import { Injectable } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs/operators'; +import posthog from 'posthog-js'; +import { environment } from 'src/environments/environment'; + +/** + * PostHog product analytics (ítem 2 del plan 0004, release v2.0.4). + * + * Mismas reglas que MatomoService: todo va envuelto en try/catch para que un + * fallo aquí (config ausente, red caída, SDK roto) nunca tumbe el app, y sin + * configuración el servicio simplemente no hace nada. + * + * Notas propias de este app: + * - Es un Electron de escritorio que vive con conectividad intermitente — es + * literalmente lo que mide. El SDK se empaqueta (no se carga por CDN como + * Matomo) para que funcione offline y encole los eventos hasta que vuelva la + * red, y para no depender de cargar un script remoto bajo la CSP de Electron. + * - En Electron `window.location` es `file://`, inútil para analítica: se usa + * el mismo host virtual que Matomo para que las URLs sean legibles. + * - Nada de PII: se identifica por giga id de la escuela, que es lo mismo que + * ya viaja en cada medición. Nunca nombre de escuela, usuario de Windows, + * rutas de instalación ni IP. + */ +@Injectable({ + providedIn: 'root', +}) +export class PosthogService { + private initialized = false; + + // Mismo host virtual que MatomoService, para que ambas herramientas reporten + // las mismas URLs y se puedan cruzar. + private readonly electronVirtualOrigin = 'https://app.gigameter.local'; + + constructor(private router: Router) {} + + /** + * Arranca PostHog. Es seguro llamarlo varias veces. + * No hace nada si falta la project API key o el host. + */ + init(): void { + try { + if (this.initialized) { + return; + } + + const apiKey = environment.posthog?.apiKey; + const host = environment.posthog?.host; + + if (!apiKey || !host) { + console.warn('[PostHog] Skipping init: missing API key or host.'); + return; + } + + posthog.init(apiKey, { + api_host: host, + // Las vistas se mandan a mano en trackRouteChanges(): con rutas hash + // de Angular el autocapture de pageviews no las ve. + capture_pageview: false, + capture_pageleave: true, + autocapture: false, // solo eventos explícitos: menos ruido y menos PII + disable_session_recording: !environment.posthog?.enableSessionRecording, + persistence: 'localStorage', // el app ya guarda su estado ahí + // La instalación puede pasar horas sin red; que el SDK reintente en vez + // de descartar. + request_batching: true, + loaded: (ph) => { + try { + ph.register({ + app_version: environment.app_version, + app_mode: environment.mode, + is_electron: !!environment.isElectron, + }); + } catch (error) { + console.warn('[PostHog] register on load failed:', error); + } + }, + }); + + this.identifyFromStorage(); + this.trackPageView(); + this.trackRouteChanges(); + + this.initialized = true; + } catch (error) { + console.warn('[PostHog] init failed:', error); + } + } + + /** + * Registra un evento. Seguro aunque PostHog no esté inicializado. + */ + capture(event: string, properties?: Record): void { + try { + if (!this.initialized) { + return; + } + posthog.capture(event, properties); + } catch (error) { + console.warn('[PostHog] capture failed:', error); + } + } + + /** + * Asocia los eventos a una escuela. Se llama al arrancar (si ya hay registro) + * y justo después de completar el registro. + * + * El identificador es el giga id: identifica al centro, no a la persona. + */ + identify(gigaId: string, properties?: Record): void { + try { + if (!this.initialized || !gigaId) { + return; + } + posthog.identify(gigaId, properties); + } catch (error) { + console.warn('[PostHog] identify failed:', error); + } + } + + /** + * Vista de página manual. Con rutas hash hay que mandarlas a mano. + */ + trackPageView(url?: string): void { + try { + if (!this.initialized) { + return; + } + const path = url ?? window.location.hash?.replace(/^#/, '') ?? '/'; + posthog.capture('$pageview', { + $current_url: this.getTrackingOrigin() + (path || '/'), + }); + } catch (error) { + console.warn('[PostHog] trackPageView failed:', error); + } + } + + /** Corta la sesión al cerrar sesión en el app, para no mezclar escuelas. */ + reset(): void { + try { + if (!this.initialized) { + return; + } + posthog.reset(); + } catch (error) { + console.warn('[PostHog] reset failed:', error); + } + } + + private identifyFromStorage(): void { + try { + const gigaId = localStorage.getItem('gigaId'); + if (gigaId) { + this.identify(gigaId); + } + } catch (error) { + console.warn('[PostHog] identifyFromStorage failed:', error); + } + } + + private trackRouteChanges(): void { + try { + this.router.events + .pipe(filter((event) => event instanceof NavigationEnd)) + .subscribe((event: NavigationEnd) => { + this.trackPageView(event.urlAfterRedirects || event.url); + }); + } catch (error) { + console.warn('[PostHog] trackRouteChanges setup failed:', error); + } + } + + /** + * Origen limpio para reportar. En Electron el real es `file://`, que no sirve + * para analítica, así que se sustituye por un host virtual fijo. + */ + private getTrackingOrigin(): string { + try { + if (environment.isElectron) { + return this.electronVirtualOrigin; + } + return window.location.origin; + } catch { + return this.electronVirtualOrigin; + } + } +} diff --git a/src/app/services/sync.service.ts b/src/app/services/sync.service.ts index aae82d5b..1e7fb5c5 100644 --- a/src/app/services/sync.service.ts +++ b/src/app/services/sync.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { IndexedDBService } from './indexed-db.service'; import { StorageService } from './storage.service'; +import { PosthogService } from './posthog.service'; import { environment } from 'src/environments/environment'; @Injectable({ @@ -13,7 +14,8 @@ export class SyncService { constructor( private http: HttpClient, private indexedDBService: IndexedDBService, - private storage: StorageService + private storage: StorageService, + private posthog: PosthogService ) { } async syncPendingMeasurements(): Promise { @@ -40,11 +42,20 @@ export class SyncService { } console.log(`Successfully synced measurements batch ${index / batchSize + 1}`); + // Mediciones que fallaron en tiempo real y se recuperan ahora: el + // otro extremo de measurement_queued_offline. + this.posthog.capture('measurements_synced', { + batch_size: batch.length, + }); } catch (error) { console.error( `Failed to sync measurement batch ${index / batchSize + 1} after retry:`, error ); + this.posthog.capture('measurements_sync_failed', { + batch_size: batch.length, + status: (error as any)?.status ?? null, + }); break; // Stop further sync until next cycle } diff --git a/src/app/services/upload.service.ts b/src/app/services/upload.service.ts index 6e53826c..4fb977f2 100644 --- a/src/app/services/upload.service.ts +++ b/src/app/services/upload.service.ts @@ -13,6 +13,7 @@ import { StorageService } from './storage.service'; import { HardwareIdService } from './hardware-id.service'; import { IndexedDBService } from './indexed-db.service'; import { LocationService } from './location.service'; +import { PosthogService } from './posthog.service'; @Injectable({ providedIn: 'root', @@ -25,7 +26,8 @@ export class UploadService { private storage: StorageService, private hardwareIdService: HardwareIdService, private indexedDB: IndexedDBService, - private locationService: LocationService + private locationService: LocationService, + private posthog: PosthogService ) {} /** @@ -202,13 +204,30 @@ export class UploadService { switchMap(measurementWithGeo => this.http.post(uploadURL, measurementWithGeo).pipe( map((res: any) => res), - tap((data) => data), + tap((data) => { + // Medición entregada en tiempo real. Sin cifras de velocidad: para + // eso está la propia tabla de mediciones; aquí interesa el embudo. + this.posthog.capture('measurement_uploaded', { + notes: measurementWithGeo.Notes, + scheduled_slot: measurementWithGeo['scheduled_slot'], + protocol: measurementWithGeo['protocol'] ?? 'mlab', + upload_failed: false, + }); + return data; + }), catchError(async (error) => { console.error('Upload failed, saving to IndexedDB...', error); await this.indexedDB.saveMeasurement({ ...measurementWithGeo, upload_failed: true, }); + // El upload en tiempo real falló y la medición queda en la cola + // local: es la señal que el flag del plan 0006 persigue. + this.posthog.capture('measurement_queued_offline', { + notes: measurementWithGeo.Notes, + scheduled_slot: measurementWithGeo['scheduled_slot'], + status: error?.status ?? null, + }); return of({ savedLocally: true, error }); }) ) diff --git a/src/environments/_environment.e2e.ts b/src/environments/_environment.e2e.ts index 229df1eb..e8fb8ce1 100644 --- a/src/environments/_environment.e2e.ts +++ b/src/environments/_environment.e2e.ts @@ -18,4 +18,12 @@ export const environment = { // The Playwright suite intercepts api.ipinfo.io, so this token is never used. ipInfoToken: 'e2e-local-token', + + // PostHog stays off in e2e: an empty key makes the service no-op, so the + // suite never sends analytics to a real project. + posthogHost: '', + posthogKeyDev: '', + posthogKeyStg: '', + posthogKeyProd: '', + posthogEnableSessionRecording: false, }; diff --git a/src/environments/_environment.prod.ts.example b/src/environments/_environment.prod.ts.example index 3f902faf..600522f4 100644 --- a/src/environments/_environment.prod.ts.example +++ b/src/environments/_environment.prod.ts.example @@ -20,6 +20,19 @@ export const environment = { matomoSiteIdDev: 'MATOMO_SITE_ID_DEV', matomoSiteIdStg: 'MATOMO_SITE_ID_STG', matomoSiteIdProd: 'MATOMO_SITE_ID_PROD', + + // PostHog product analytics (per-environment project API keys). + // The "project API key" is PostHog's public write key — the equivalent of a + // Sentry DSN. It is embedded in the client build, so use a project key, never + // a personal API key. + // Leaving a key empty disables PostHog for that mode: no key, no tracking. + posthogHost: 'POSTHOG_HOST', // e.g. https://eu.i.posthog.com + posthogKeyDev: 'POSTHOG_PROJECT_API_KEY_DEV', + posthogKeyStg: 'POSTHOG_PROJECT_API_KEY_STG', + posthogKeyProd: 'POSTHOG_PROJECT_API_KEY_PROD', + // Session replay records the user's screen. Off unless explicitly enabled — + // scope is still being defined (plan 0004, item 2). + posthogEnableSessionRecording: false, languages: [ { name: 'En', diff --git a/src/environments/environment.ts b/src/environments/environment.ts index cfc8e7c2..83d8525b 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -5,16 +5,19 @@ const environmentConfig = { restApi: env.restAPI, token: env.token, matomoSiteId: (env as any).matomoSiteIdProd, + posthogKey: (env as any).posthogKeyProd, }, dev: { restApi: env.restAPIDev, token: env.tokenDev, matomoSiteId: (env as any).matomoSiteIdDev, + posthogKey: (env as any).posthogKeyDev, }, stg: { restApi: env.restAPIStg, token: env.tokenStg, matomoSiteId: (env as any).matomoSiteIdStg, + posthogKey: (env as any).posthogKeyStg, }, }; export const environment = { @@ -28,6 +31,17 @@ export const environment = { trackerUrl: (env as any).matomoTrackerUrl as string, siteId: environmentConfig[env.mode].matomoSiteId as string, }, + posthog: { + // Project API key de PostHog (lo que en Sentry sería el DSN). Sin clave, el + // servicio no arranca: un build sin configurar simplemente no manda nada. + apiKey: environmentConfig[env.mode].posthogKey as string, + host: (env as any).posthogHost as string, + // Session replay graba la pantalla del usuario. Queda apagado salvo que se + // active explícitamente: el alcance lo está definiendo el research de + // Shilpa (ítem 2 del plan 0004) y en escuelas es decisión de privacidad. + enableSessionRecording: + (env as any).posthogEnableSessionRecording === true, + }, app_version: '2.0.3', appName: 'Giga Meter', appNameSuffix: '', From 3e8bc269231bcd94337745bc82f17f28beb24abd Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Tue, 18 Aug 2026 01:28:06 +0200 Subject: [PATCH 15/22] feat: report Electron auto-update events to PostHog over IPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the PostHog integration: the desktop shell now reports the one thing neither the renderer nor the backend can see — the auto-update lifecycle. A machine that fails to update stops sending measurements, so it silently vanishes from the app_version adoption queries. The main process gets no PostHog SDK or key of its own. posthog-node was implemented first and dropped: - Most of what it added was already covered. Main-process errors go to Sentry (@sentry/node), and version adoption comes from app_version on every measurement (project-memory/technical/APP_VERSION_ADOPTION_QUERIES.md). - posthog-node does not persist its queue between sessions; posthog-js does, in localStorage. On a machine that spends hours offline, adding the node SDK would have made shell telemetry the *less* reliable half, and it forced a bounded flush on quit that risked hanging app shutdown. So the main process emits `desktop_update_downloaded` / `desktop_update_failed` on a `telemetry-event` IPC channel (exposed through the preload as electronAPI.onTelemetryEvent) and the renderer publishes them with the key it already has. Events are dropped if the window is not alive, which is acceptable: update failures already reach Sentry independently. tsc clean on both the main process and the renderer. Plan: project-memory/plans/0004-release-v2.0.4-2026-08.md (giga repo) Co-Authored-By: Claude Opus 5 --- electron/src/index.ts | 27 ++++++++++++++ electron/src/preload.ts | 8 +++++ src/app/services/posthog.service.ts | 35 +++++++++++++++++++ src/environments/_environment.prod.ts.example | 4 +++ 4 files changed, 74 insertions(+) diff --git a/electron/src/index.ts b/electron/src/index.ts index e403e324..fde813ee 100644 --- a/electron/src/index.ts +++ b/electron/src/index.ts @@ -45,6 +45,29 @@ unhandled({ let mainWindow = null; let isDownloaded = false; +/** + * Manda un evento de telemetría al renderer, que lo publica en PostHog. + * + * El SDK del renderer (posthog-js) persiste su cola en localStorage y sobrevive + * a reinicios; `posthog-node` en el main process no, y este equipo pasa horas + * sin red. Por eso el main no habla con PostHog directamente: solo el ciclo de + * vida del auto-update, que es lo único que ni el renderer ni el backend ven + * (un equipo que falla al actualizar deja de mandar mediciones y desaparece de + * las queries de adopción). + * + * Si la ventana no está viva el evento se pierde, y es aceptable: el fallo de + * update ya va a Sentry por separado. + */ +function sendTelemetry(event: string, properties: Record = {}) { + try { + if (!mainWindow || mainWindow.isDestroyed()) return; + if (!mainWindow.webContents || mainWindow.webContents.isDestroyed()) return; + mainWindow.webContents.send('telemetry-event', { event, properties }); + } catch (error) { + console.warn('[telemetry] send failed:', error); + } +} + // Define our menu templates (these are optional) const trayMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [ new MenuItem({ @@ -217,6 +240,7 @@ if (!gotTheLock) { }, 3600000); autoUpdater.on('update-downloaded', (_event, releaseNotes, releaseName) => { + sendTelemetry('desktop_update_downloaded', { release_name: releaseName }); const dialogOpts = { type: 'info' as const, buttons: ['Restart / Reinicie. / Перезапуск', 'Later / Después / Позже'], @@ -278,6 +302,9 @@ if (!gotTheLock) { autoUpdater.on('error', (error) => { console.error('Update Error:', error); captureException(error); + sendTelemetry('desktop_update_failed', { + message: error?.message ?? null, + }); }); } /* diff --git a/electron/src/preload.ts b/electron/src/preload.ts index 9409c591..bd5d1993 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -38,4 +38,12 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.removeAllListeners('system-hardware-id'); ipcRenderer.removeAllListeners('system-hardware-id-error'); }, + // Eventos del main process (ciclo de vida del auto-update) que el renderer + // publica en PostHog: su SDK persiste la cola en localStorage y sobrevive a + // reinicios sin red, cosa que el SDK de node no hace. + onTelemetryEvent: ( + callback: (payload: { event: string; properties?: any }) => void + ) => { + ipcRenderer.on('telemetry-event', (event, payload) => callback(payload)); + }, }); diff --git a/src/app/services/posthog.service.ts b/src/app/services/posthog.service.ts index 73b530a5..8f0d28fd 100644 --- a/src/app/services/posthog.service.ts +++ b/src/app/services/posthog.service.ts @@ -80,6 +80,7 @@ export class PosthogService { this.identifyFromStorage(); this.trackPageView(); this.trackRouteChanges(); + this.bridgeMainProcessEvents(); this.initialized = true; } catch (error) { @@ -147,6 +148,40 @@ export class PosthogService { } } + /** + * Reenvía a PostHog los eventos que emite el main process de Electron. + * + * El main no habla con PostHog directamente: `posthog-node` no persiste su + * cola entre sesiones y este equipo pasa horas sin red, así que sus eventos + * serían los menos fiables justo donde más cuesta recuperarlos. El SDK del + * renderer sí encola en localStorage y sobrevive a reinicios. + * + * Hoy solo llega el ciclo de vida del auto-update, que es lo único que ni el + * renderer ni el backend ven: un equipo que falla al actualizar deja de + * mandar mediciones y desaparece de las queries de adopción de versiones. + */ + private bridgeMainProcessEvents(): void { + try { + const electronAPI = (window as any).electronAPI; + if (!electronAPI?.onTelemetryEvent) { + return; // navegador, o build sin el preload nuevo + } + electronAPI.onTelemetryEvent( + (payload: { event: string; properties?: Record }) => { + if (!payload?.event) { + return; + } + this.capture(payload.event, { + ...(payload.properties ?? {}), + source: 'electron-main', + }); + } + ); + } catch (error) { + console.warn('[PostHog] bridgeMainProcessEvents failed:', error); + } + } + private identifyFromStorage(): void { try { const gigaId = localStorage.getItem('gigaId'); diff --git a/src/environments/_environment.prod.ts.example b/src/environments/_environment.prod.ts.example index 600522f4..117a354a 100644 --- a/src/environments/_environment.prod.ts.example +++ b/src/environments/_environment.prod.ts.example @@ -30,6 +30,10 @@ export const environment = { posthogKeyDev: 'POSTHOG_PROJECT_API_KEY_DEV', posthogKeyStg: 'POSTHOG_PROJECT_API_KEY_STG', posthogKeyProd: 'POSTHOG_PROJECT_API_KEY_PROD', + // The Electron main process has no key of its own: it forwards its two + // auto-update events to the renderer over IPC, which publishes them with the + // keys above. posthog-node would not survive a restart offline; posthog-js + // persists its queue in localStorage, and this app is offline a lot. // Session replay records the user's screen. Off unless explicitly enabled — // scope is still being defined (plan 0004, item 2). posthogEnableSessionRecording: false, From 25d4b70a6aa8c4467f7e6a42bae4f47e65c194e7 Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Mon, 24 Aug 2026 16:20:27 +0200 Subject: [PATCH 16/22] Revert "feat: use @m-lab/ndt7 npm package instead of vendored copies" Reverts b1b65e5 and c9b1796, restoring the vendored ndt7 client under src/assets/js/ndt/. The npm package is a black box from the app's point of view, and the next commit needs to reach into the client itself: ndt7 exposes no server-side wall clock, so capturing one means editing discoverServerURLs. Owning the file again is the cheapest way to do that without carrying a patched fork of the package. Restores: ndt7.js and both workers, @m-lab/ndt7 back to ^0.0.6, the angular.json assets glob and allowedCommonJsDependencies entry, and the vendored import in measurement-client. Drops src/types/ndt7.d.ts, which only existed to type the package. Co-Authored-By: Claude Opus 5 --- angular.json | 8 +- package-lock.json | 51 ++- package.json | 2 +- .../measurement-client.service.spec.ts | 72 ---- .../services/measurement-client.service.ts | 10 +- src/assets/js/ndt/ndt7-download-worker.js | 99 +++++ src/assets/js/ndt/ndt7-upload-worker.js | 168 +++++++++ src/assets/js/ndt/ndt7.js | 337 ++++++++++++++++++ src/types/ndt7.d.ts | 44 --- 9 files changed, 648 insertions(+), 143 deletions(-) create mode 100644 src/assets/js/ndt/ndt7-download-worker.js create mode 100644 src/assets/js/ndt/ndt7-upload-worker.js create mode 100644 src/assets/js/ndt/ndt7.js delete mode 100644 src/types/ndt7.d.ts diff --git a/angular.json b/angular.json index 74e9fddc..96adebdd 100644 --- a/angular.json +++ b/angular.json @@ -28,11 +28,6 @@ "input": "src/assets", "output": "assets" }, - { - "glob": "ndt7-*-worker.js", - "input": "node_modules/@m-lab/ndt7/src", - "output": "assets/js/ndt" - }, { "glob": "**/*.svg", "input": "node_modules/ionicons/dist/ionicons/svg", @@ -50,8 +45,7 @@ "namedChunks": true, "allowedCommonJsDependencies": [ "electron", - "@electron/remote", - "@m-lab/ndt7" + "@electron/remote" ] }, "configurations": { diff --git a/package-lock.json b/package-lock.json index 8d473719..94928188 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@cloudflare/speedtest": "^1.4.1", "@electron/remote": "^2.1.2", "@ionic/angular": "^6.0.3", - "@m-lab/ndt7": "^0.1.5", + "@m-lab/ndt7": "^0.0.6", "@ngx-translate/core": "^14.0.0", "@ngx-translate/http-loader": "^7.0.0", "@sentry/browser": "^5.5.0", @@ -10160,12 +10160,42 @@ ] }, "node_modules/@m-lab/ndt7": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@m-lab/ndt7/-/ndt7-0.1.5.tgz", - "integrity": "sha512-PlfHJ4wBUSt9yMWo2NUQmXWmmTVNaSGK914qh+G+IfLp4KBCxGlL1zDzrP7gucoSMppHoP0x0yLxaxB2t/A9jg==", + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@m-lab/ndt7/-/ndt7-0.0.6.tgz", + "integrity": "sha512-vOnbJETYUqg8Tj6V3tLshj7Nch4SmuJGPVmedIkC7V/x7LTWNHU98RdYcZbbhMWoPOrqGwhOylkqZZtjXdi0BA==", "license": "Apache-2.0", + "dependencies": { + "node-fetch": "^2.6.0", + "workerjs": "^0.1.1", + "ws": "^8.5.0" + }, "engines": { - "node": ">=18" + "node": ">=12" + }, + "optionalDependencies": { + "bufferutil": "^4.0.6", + "utf-8-validate": "^5.0.8" + } + }, + "node_modules/@m-lab/ndt7/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, "node_modules/@malept/cross-spawn-promise": { @@ -14564,11 +14594,9 @@ "version": "4.0.9", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -24597,7 +24625,6 @@ "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "dev": true, "license": "MIT", "optional": true, "bin": { @@ -32978,11 +33005,9 @@ "version": "5.0.10", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -33962,6 +33987,12 @@ "node": ">=0.10.0" } }, + "node_modules/workerjs": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/workerjs/-/workerjs-0.1.1.tgz", + "integrity": "sha512-fMlithUrdswVB/bDtrncuXeuIOwc4hS+LXsAZNjdcpoOjU0rw1TFV2I5IlCwx6hysU2IveI8uWlkf5mTAQXHcw==", + "license": "BSD-3-Clause" + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", diff --git a/package.json b/package.json index f70342c3..af378192 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@cloudflare/speedtest": "^1.4.1", "@electron/remote": "^2.1.2", "@ionic/angular": "^6.0.3", - "@m-lab/ndt7": "^0.1.5", + "@m-lab/ndt7": "^0.0.6", "@ngx-translate/core": "^14.0.0", "@ngx-translate/http-loader": "^7.0.0", "@sentry/browser": "^5.5.0", diff --git a/src/app/services/measurement-client.service.spec.ts b/src/app/services/measurement-client.service.spec.ts index 6c713498..1d26111d 100644 --- a/src/app/services/measurement-client.service.spec.ts +++ b/src/app/services/measurement-client.service.spec.ts @@ -2,9 +2,7 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common import { TestBed } from '@angular/core/testing'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { Network } from '@awesome-cordova-plugins/network/ngx'; -import ndt7 from '@m-lab/ndt7'; import { MeasurementClientService } from './measurement-client.service'; -import { environment } from '../../environments/environment'; describe('MeasurementClientService', () => { let service: MeasurementClientService; @@ -25,73 +23,3 @@ describe('MeasurementClientService', () => { expect(service).toBeTruthy(); }); }); - -describe('MeasurementClientService ndt7 package integration', () => { - let service: MeasurementClientService; - let ndt7TestSpy: jasmine.Spy; - - beforeEach(() => { - ndt7TestSpy = spyOn(ndt7, 'test').and.resolveTo(0); - - const historyService: any = { add: jasmine.createSpy('add') }; - const settingsService: any = { - get: jasmine.createSpy('get').and.returnValue(false), - currentSettings: { uploadEnabled: false }, - }; - const networkService: any = { - getNetInfo: jasmine.createSpy('getNetInfo').and.resolveTo({}), - }; - const uploadService: any = { - uploadMeasurement: jasmine.createSpy('uploadMeasurement'), - }; - const sharedService: any = { - broadcast: jasmine.createSpy('broadcast'), - on: jasmine.createSpy('on'), - }; - - service = new MeasurementClientService( - historyService, - settingsService, - networkService, - uploadService, - sharedService - ); - spyOn(service, 'finalizeMeasurement').and.resolveTo(undefined); - }); - - it('runs the test through the npm package with the giga-meter metadata', async () => { - await service.runTest('manual'); - - expect(ndt7TestSpy).toHaveBeenCalledTimes(1); - const config = ndt7TestSpy.calls.mostRecent().args[0]; - expect(config.metadata).toEqual({ - client_name: 'giga-meter', - client_version: environment.app_version, - }); - expect(config.userAcceptedDataPolicy).toBeTrue(); - expect(config.downloadworkerfile).toBe( - 'assets/js/ndt/ndt7-download-worker.js' - ); - expect(config.uploadworkerfile).toBe('assets/js/ndt/ndt7-upload-worker.js'); - }); - - it('still classifies locate-server failures as retryable', async () => { - (service as any).maxRetries = 1; - ndt7TestSpy.and.rejectWith( - new Error('TypeError: Failed to fetch locate.measurementlab.net') - ); - - await service.runTest('manual'); - - // one initial attempt + one retry, then it gives up - expect(ndt7TestSpy).toHaveBeenCalledTimes(2); - }); - - it('does not retry non-locate test failures', async () => { - ndt7TestSpy.and.rejectWith(new Error('websocket closed unexpectedly')); - - await service.runTest('manual'); - - expect(ndt7TestSpy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/app/services/measurement-client.service.ts b/src/app/services/measurement-client.service.ts index 53b39186..578e99d6 100644 --- a/src/app/services/measurement-client.service.ts +++ b/src/app/services/measurement-client.service.ts @@ -1,6 +1,5 @@ import { Injectable } from '@angular/core'; -import ndt7 from '@m-lab/ndt7'; -import { environment } from '../../environments/environment'; +import ndt7 from '../../assets/js/ndt/ndt7.js'; import { BehaviorSubject, Subject } from 'rxjs'; import { HistoryService } from './history.service'; import { SettingsService } from './settings.service'; @@ -26,15 +25,8 @@ export class MeasurementClientService { ).asObservable(); private readonly testConfig = { userAcceptedDataPolicy: true, - // Served from node_modules/@m-lab/ndt7 via the angular.json assets glob downloadworkerfile: 'assets/js/ndt/ndt7-download-worker.js', uploadworkerfile: 'assets/js/ndt/ndt7-upload-worker.js', - // Identifies our measurements in the M-Lab dataset (was hardcoded in the - // vendored copy of ndt7.js before) - metadata: { - client_name: 'giga-meter', - client_version: environment.app_version, - }, }; mlabInformation = { diff --git a/src/assets/js/ndt/ndt7-download-worker.js b/src/assets/js/ndt/ndt7-download-worker.js new file mode 100644 index 00000000..ae02d39f --- /dev/null +++ b/src/assets/js/ndt/ndt7-download-worker.js @@ -0,0 +1,99 @@ +/* eslint-env browser, node, worker */ + +// workerMain is the WebWorker function that runs the ndt7 download test. +const workerMain = function(ev) { + 'use strict'; + const url = ev.data['///ndt/v7/download']; + const sock = new WebSocket(url, 'net.measurementlab.ndt.v7'); + let now; + if (typeof performance !== 'undefined' && + typeof performance.now === 'function') { + now = () => performance.now(); + } else { + now = () => Date.now(); + } + downloadTest(sock, postMessage, now); +}; + +/** + * downloadTest is a function that runs an ndt7 download test using the + * passed-in websocket instance and the passed-in callback function. The + * socket and callback are passed in to enable testing and mocking. + * + * @param {WebSocket} sock - The WebSocket being read. + * @param {function} postMessage - A function for messages to the main thread. + * @param {function} now - A function returning a time in milliseconds. + */ +const downloadTest = function(sock, postMessage, now) { + sock.onclose = function() { + postMessage({ + MsgType: 'complete', + }); + }; + + sock.onerror = function(ev) { + postMessage({ + MsgType: 'error', + Error: ev.type, + }); + }; + + let start = now(); + let previous = start; + let total = 0; + + sock.onopen = function() { + start = now(); + previous = start; + total = 0; + postMessage({ + MsgType: 'start', + Data: { + ClientStartTime: start, + }, + }); + }; + + sock.onmessage = function(ev) { + total += + (typeof ev.data.size !== 'undefined') ? ev.data.size : ev.data.length; + // Perform a client-side measurement 4 times per second. + const t = now(); + const every = 250; // ms + if (t - previous > every) { + postMessage({ + MsgType: 'measurement', + ClientData: { + ElapsedTime: (t - start) / 1000, // seconds + NumBytes: total, + // MeanClientMbps is calculated via the logic: + // (bytes) * (bits / byte) * (megabits / bit) = Megabits + // (Megabits) * (1/milliseconds) * (milliseconds / second) = Mbps + // Collect the conversion constants, we find it is 8*1000/1000000 + // When we simplify we get: 8*1000/1000000 = .008 + MeanClientMbps: (total / (t - start)) * 0.008, + }, + Source: 'client', + }); + previous = t; + } + + // Pass along every server-side measurement. + if (typeof ev.data === 'string') { + postMessage({ + MsgType: 'measurement', + ServerMessage: ev.data, + Source: 'server', + }); + } + }; +}; + +// Node and browsers get onmessage defined differently. +if (typeof self !== 'undefined') { + self.onmessage = workerMain; +} else if (typeof this !== 'undefined') { + this.onmessage = workerMain; +} else if (typeof onmessage !== 'undefined') { + onmessage = workerMain; +} diff --git a/src/assets/js/ndt/ndt7-upload-worker.js b/src/assets/js/ndt/ndt7-upload-worker.js new file mode 100644 index 00000000..a1d55e01 --- /dev/null +++ b/src/assets/js/ndt/ndt7-upload-worker.js @@ -0,0 +1,168 @@ +/* eslint-env es6, browser, node, worker */ + +// WebWorker that runs the ndt7 upload test +const workerMain = function(ev) { + const url = ev.data['///ndt/v7/upload']; + const sock = new WebSocket(url, 'net.measurementlab.ndt.v7'); + let now; + if (typeof performance !== 'undefined' && + typeof performance.now === 'function') { + now = () => performance.now(); + } else { + now = () => Date.now(); + } + uploadTest(sock, postMessage, now); +}; + +const uploadTest = function(sock, postMessage, now) { + let closed = false; + sock.onclose = function() { + if (!closed) { + closed = true; + postMessage({ + MsgType: 'complete', + }); + } + }; + + sock.onerror = function(ev) { + postMessage({ + MsgType: 'error', + Error: ev.type, + }); + }; + + sock.onmessage = function(ev) { + if (typeof ev.data !== 'undefined') { + postMessage({ + MsgType: 'measurement', + Source: 'server', + ServerMessage: ev.data, + }); + } + }; + + /** + * uploader is the main loop that uploads data in the web browser. It must + * carefully balance a bunch of factors: + * 1) message size determines measurement granularity on the client side, + * 2) the JS event loop can only fire off so many times per second, and + * 3) websocket buffer tracking seems inconsistent between browsers. + * + * Because of (1), we need to have small messages on slow connections, or + * else this will not accurately measure slow connections. Because of (2), if + * we use small messages on fast connections, then we will not fill the link. + * Because of (3), we can't depend on the websocket buffer to "fill up" in a + * reasonable amount of time. + * + * So on fast connections we need a big message size (one the message has + * been handed off to the browser, it runs on the browser's fast compiled + * internals) and on slow connections we need a small message. Because this + * is used as a speed test, we don't know before the test which strategy we + * will be using, because we don't know the speed before we test it. + * Therefore, we use a strategy where we grow the message exponentially over + * time. In an effort to be kind to the memory allocator, we always double + * the message size instead of growing it by e.g. 1.3x. + * + * @param {*} data + * @param {*} start + * @param {*} end + * @param {*} previous + * @param {*} total + */ + function uploader(data, start, end, previous, total) { + if (closed) { + // socket.send() with too much buffering causes socket.close(). We only + // observed this behaviour with pre-Chromium Edge. + return; + } + const t = now(); + if (t >= end) { + sock.close(); + // send one last measurement. + postClientMeasurement(total, sock.bufferedAmount, start); + return; + } + + const maxMessageSize = 8388608; /* = (1<<23) = 8MB */ + const clientMeasurementInterval = 250; // ms + + // Message size is doubled after the first 16 messages, and subsequently + // every 8, up to maxMessageSize. + const nextSizeIncrement = + (data.length >= maxMessageSize) ? Infinity : 16 * data.length; + if ((total - sock.bufferedAmount) >= nextSizeIncrement) { + data = new Uint8Array(data.length * 2); + } + + // We keep 7 messages in the send buffer, so there is always some more + // data to send. The maximum buffer size is 8 * 8MB - 1 byte ~= 64M. + const desiredBuffer = 7 * data.length; + if (sock.bufferedAmount < desiredBuffer) { + sock.send(data); + total += data.length; + } + + if (t >= previous + clientMeasurementInterval) { + postClientMeasurement(total, sock.bufferedAmount, start); + previous = t; + } + + // Loop the uploader function in a way that respects the JS event handler. + setTimeout(() => uploader(data, start, end, previous, total), 0); + } + + /** Report measurement back to the main thread. + * + * @param {*} total + * @param {*} bufferedAmount + * @param {*} start + */ + function postClientMeasurement(total, bufferedAmount, start) { + // bytes sent - bytes buffered = bytes actually sent + const numBytes = total - bufferedAmount; + // ms / 1000 = seconds + const elapsedTime = (now() - start) / 1000; + // bytes * bits/byte * megabits/bit * 1/seconds = Mbps + const meanMbps = numBytes * 8 / 1000000 / elapsedTime; + postMessage({ + MsgType: 'measurement', + ClientData: { + ElapsedTime: elapsedTime, + NumBytes: numBytes, + MeanClientMbps: meanMbps, + }, + Source: 'client', + Test: 'upload', + }); + } + + sock.onopen = function() { + const initialMessageSize = 8192; /* (1<<13) = 8kBytes */ + // TODO(bassosimone): fill this message - see above comment + const data = new Uint8Array(initialMessageSize); + const start = now(); // ms since epoch + const duration = 10000; // ms + const end = start + duration; // ms since epoch + + postMessage({ + MsgType: 'start', + Data: { + StartTime: start / 1000, // seconds since epoch + ExpectedEndTime: end / 1000, // seconds since epoch + }, + }); + + // Start the upload loop. + uploader(data, start, end, start, 0); + }; +}; + +// Node and browsers get onmessage defined differently. +if (typeof self !== 'undefined') { + self.onmessage = workerMain; +} else if (typeof this !== 'undefined') { + this.onmessage = workerMain; +} else if (typeof onmessage !== 'undefined') { + onmessage = workerMain; +} diff --git a/src/assets/js/ndt/ndt7.js b/src/assets/js/ndt/ndt7.js new file mode 100644 index 00000000..50aa50bf --- /dev/null +++ b/src/assets/js/ndt/ndt7.js @@ -0,0 +1,337 @@ +/* eslint-env browser, node, worker */ + +// ndt7 contains the core ndt7 client functionality. Please, refer +// to the ndt7 spec available at the following URL: +// +// https://github.com/m-lab/ndt-server/blob/master/spec/ndt7-protocol.md +// +// This implementation uses v0.9.0 of the spec. + +// Wrap everything in a closure to ensure that local definitions don't +// permanently shadow global definitions. +(function () { + "use strict"; + + /** + * @name ndt7 + * @namespace ndt7 + */ + const ndt7 = (function () { + const staticMetadata = { + client_library_name: "ndt7-js", + client_library_version: "0.0.6", + client_name: "giga-meter", + }; + // cb creates a default-empty callback function, allowing library users to + // only need to specify callback functions for the events they care about. + // + // This function is not exported. + const cb = function (name, callbacks, defaultFn) { + if (typeof callbacks !== "undefined" && name in callbacks) { + return callbacks[name]; + } else if (typeof defaultFn !== "undefined") { + return defaultFn; + } else { + // If no default function is provided, use the empty function. + return function () {}; + } + }; + + // The default response to an error is to throw an exception. + const defaultErrCallback = function (err) { + throw new Error(err); + }; + + /** + * discoverServerURLs contacts a web service (likely the Measurement Lab + * locate service, but not necessarily) and gets URLs with access tokens in + * them for the client. It can be short-circuted if config.server exists, + * which is useful for clients served from the webserver of an NDT server. + * + * @param {Object} config - An associative array of configuration options. + * @param {Object} userCallbacks - An associative array of user callbacks. + * + * It uses the callback functions `error`, `serverDiscovery`, and + * `serverChosen`. + * + * @name ndt7.discoverServerURLS + * @public + */ + async function discoverServerURLs(config, userCallbacks) { + config.metadata = Object.assign({}, config.metadata); + config.metadata = Object.assign(config.metadata, staticMetadata); + const callbacks = { + error: cb("error", userCallbacks, defaultErrCallback), + serverDiscovery: cb("serverDiscovery", userCallbacks), + serverChosen: cb("serverChosen", userCallbacks), + }; + let protocol = "wss"; + if (config && "protocol" in config) { + protocol = config.protocol; + } + + const metadata = new URLSearchParams(config.metadata); + // If a server was specified, use it. + if (config && "server" in config) { + // Add metadata as querystring parameters. + const downloadURL = new URL( + protocol + "://" + config.server + "/ndt/v7/download" + ); + const uploadURL = new URL( + protocol + "://" + config.server + "/ndt/v7/upload" + ); + downloadURL.search = metadata; + uploadURL.search = metadata; + return { + "///ndt/v7/download": downloadURL.toString(), + "///ndt/v7/upload": uploadURL.toString(), + }; + } + + // If no server was specified then use a loadbalancer. If no loadbalancer + // is specified, use the locate service from Measurement Lab. + const lbURL = + config && "loadbalancer" in config + ? new URL(config.loadbalancer) + : new URL("https://locate.measurementlab.net/v2/nearest/ndt/ndt7"); + lbURL.search = metadata; + callbacks.serverDiscovery({ loadbalancer: lbURL }); + const response = await fetch(lbURL).catch((err) => { + throw new Error(err); + }); + const js = await response.json(); + if (!("results" in js)) { + callbacks.error(`Could not understand response from ${lbURL}: ${js}`); + return {}; + } + + // TODO: do not discard unused results. If the first server is unavailable + // the client should quickly try the next server. + // + // Choose the first result sent by the load balancer. This ensures that + // in cases where we have a single pod in a metro, that pod is used to + // run the measurement. When there are multiple pods in the same metro, + // they are randomized by the load balancer already. + const choice = js.results[0]; + callbacks.serverChosen(choice); + + return { + "///ndt/v7/download": choice.urls[protocol + ":///ndt/v7/download"], + "///ndt/v7/upload": choice.urls[protocol + ":///ndt/v7/upload"], + }; + } + + /* + * runNDT7Worker is a helper function that runs a webworker. It uses the + * callback functions `error`, `start`, `measurement`, and `complete`. It + * returns a c-style return code. 0 is success, non-zero is some kind of + * failure. + * + * @private + */ + const runNDT7Worker = async function ( + config, + callbacks, + urlPromise, + filename, + testType + ) { + if ( + config.userAcceptedDataPolicy !== true && + config.mlabDataPolicyInapplicable !== true + ) { + callbacks.error( + "The M-Lab data policy is applicable and the user " + + "has not explicitly accepted that data policy." + ); + return 1; + } + + let clientMeasurement; + let serverMeasurement; + + // This makes the worker. The worker won't actually start until it + // receives a message. + const worker = new Worker(filename); + + // When the workerPromise gets resolved it will terminate the worker. + // Workers are resolved with c-style return codes. 0 for success, + // non-zero for failure. + const workerPromise = new Promise((resolve) => { + worker.resolve = function (returnCode) { + if (returnCode == 0) { + callbacks.complete({ + LastClientMeasurement: clientMeasurement, + LastServerMeasurement: serverMeasurement, + }); + } + worker.terminate(); + resolve(returnCode); + }; + }); + + // If the worker takes 12 seconds, kill it and return an error code. + // Most clients take longer than 10 seconds to complete the upload and + // finish sending the buffer's content, sometimes hitting the socket's + // timeout of 15 seconds. This makes sure uploads terminate on time and + // get a chance to send one last measurement after 10s. + const workerTimeout = setTimeout(() => worker.resolve(0), 12000); + + // This is how the worker communicates back to the main thread of + // execution. The MsgTpe of `ev` determines which callback the message + // gets forwarded to. + worker.onmessage = function (ev) { + if (!ev.data || !ev.data.MsgType || ev.data.MsgType === "error") { + clearTimeout(workerTimeout); + worker.resolve(1); + const msg = !ev.data ? `${testType} error` : ev.data.Error; + callbacks.error(msg); + } else if (ev.data.MsgType === "start") { + callbacks.start(ev.data.Data); + } else if (ev.data.MsgType == "measurement") { + // For performance reasons, we parse the JSON outside of the thread + // doing the downloading or uploading. + if (ev.data.Source == "server") { + serverMeasurement = JSON.parse(ev.data.ServerMessage); + callbacks.measurement({ + Source: ev.data.Source, + Data: serverMeasurement, + }); + } else { + clientMeasurement = ev.data.ClientData; + callbacks.measurement({ + Source: ev.data.Source, + Data: ev.data.ClientData, + }); + } + } else if (ev.data.MsgType == "complete") { + clearTimeout(workerTimeout); + worker.resolve(0); + } + }; + + // We can't start the worker until we know the right server, so we wait + // here to find that out. + const urls = await urlPromise.catch((err) => { + // Clear timer, terminate the worker and rethrow the error. + clearTimeout(workerTimeout); + worker.resolve(2); + throw err; + }); + + // Start the worker. + worker.postMessage(urls); + + // Await the resolution of the workerPromise. + return await workerPromise; + + // Liveness guarantee - once the promise is resolved, .terminate() has + // been called and the webworker will be terminated or in the process of + // being terminated. + }; + + /** + * downloadTest runs just the NDT7 download test. + * @param {Object} config - An associative array of configuration strings + * @param {Object} userCallbacks + * @param {Object} urlPromise - A promise that will resolve to urls. + * + * @return {number} Zero on success, and non-zero error code on failure. + * + * @name ndt7.downloadTest + * @public + */ + async function downloadTest(config, userCallbacks, urlPromise) { + const callbacks = { + error: cb("error", userCallbacks, defaultErrCallback), + start: cb("downloadStart", userCallbacks), + measurement: cb("downloadMeasurement", userCallbacks), + complete: cb("downloadComplete", userCallbacks), + }; + const workerfile = config.downloadworkerfile || "ndt7-download-worker.js"; + return await runNDT7Worker( + config, + callbacks, + urlPromise, + workerfile, + "download" + ).catch((err) => { + callbacks.error(err); + }); + } + + /** + * uploadTest runs just the NDT7 download test. + * @param {Object} config - An associative array of configuration strings + * @param {Object} userCallbacks + * @param {Object} urlPromise - A promise that will resolve to urls. + * + * @return {number} Zero on success, and non-zero error code on failure. + * + * @name ndt7.uploadTest + * @public + */ + async function uploadTest(config, userCallbacks, urlPromise) { + const callbacks = { + error: cb("error", userCallbacks, defaultErrCallback), + start: cb("uploadStart", userCallbacks), + measurement: cb("uploadMeasurement", userCallbacks), + complete: cb("uploadComplete", userCallbacks), + }; + const workerfile = config.uploadworkerfile || "ndt7-upload-worker.js"; + const rv = await runNDT7Worker( + config, + callbacks, + urlPromise, + workerfile, + "upload" + ).catch((err) => { + callbacks.error(err); + }); + return rv << 4; + } + + /** + * test discovers a server to run against and then runs a download test + * followed by an upload test. + * + * @param {Object} config - An associative array of configuration strings + * @param {Object} userCallbacks + * + * @return {number} Zero on success, and non-zero error code on failure. + * + * @name ndt7.test + * @public + */ + async function test(config, userCallbacks) { + // Starts the asynchronous process of server discovery, allowing other + // stuff to proceed in the background. + const urlPromise = discoverServerURLs(config, userCallbacks); + const downloadSuccess = await downloadTest( + config, + userCallbacks, + urlPromise + ); + const uploadSuccess = await uploadTest(config, userCallbacks, urlPromise); + return downloadSuccess + uploadSuccess; + } + + return { + discoverServerURLs: discoverServerURLs, + downloadTest: downloadTest, + uploadTest: uploadTest, + test: test, + }; + })(); + + // Modules are used by `require`, if this file is included on a web page, then + // module will be undefined and we use the window.ndt7 piece. + if (typeof module !== "undefined" && typeof module.exports !== "undefined") { + module.exports = ndt7; + } else { + window.ndt7 = ndt7; + } +})(); + +// Export the ndt7 object as a default export +export default ndt7; diff --git a/src/types/ndt7.d.ts b/src/types/ndt7.d.ts deleted file mode 100644 index af3fbb1e..00000000 --- a/src/types/ndt7.d.ts +++ /dev/null @@ -1,44 +0,0 @@ -declare module '@m-lab/ndt7' { - export interface Ndt7Config { - userAcceptedDataPolicy?: boolean; - mlabDataPolicyInapplicable?: boolean; - downloadworkerfile?: string; - uploadworkerfile?: string; - server?: string; - protocol?: string; - loadbalancer?: string; - clientRegistrationToken?: string; - metadata?: Record; - } - - export interface Ndt7Callbacks { - error?: (err: any) => void; - serverDiscovery?: (data: { loadbalancer: URL }) => void; - serverChosen?: (server: any) => void; - downloadStart?: (data: any) => void; - downloadMeasurement?: (data: any) => void; - downloadComplete?: (data: any) => void; - uploadStart?: (data: any) => void; - uploadMeasurement?: (data: any) => void; - uploadComplete?: (data: any) => void; - } - - const ndt7: { - discoverServerURLs: ( - config: Ndt7Config, - userCallbacks: Ndt7Callbacks - ) => Promise; - downloadTest: ( - config: Ndt7Config, - userCallbacks: Ndt7Callbacks, - urlPromise: Promise - ) => Promise; - uploadTest: ( - config: Ndt7Config, - userCallbacks: Ndt7Callbacks, - urlPromise: Promise - ) => Promise; - test: (config: Ndt7Config, userCallbacks: Ndt7Callbacks) => Promise; - }; - export default ndt7; -} From dc200ea9bb02faa3a1b195860600b532d4a01747 Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Mon, 24 Aug 2026 16:20:45 +0200 Subject: [PATCH 17/22] feat: capture M-Lab's server clock alongside each measurement Measurements are stamped with Date.now(), so the timestamp is only as trustworthy as the clock of the machine running the test - and on these machines it frequently is not. ndt7 has no wall clock of its own: the measurement messages the server sends during a test carry only ElapsedTime, relative to the start of the test, and the browser WebSocket API does not expose the headers of the handshake response. The one server clock within reach is the Date header of the locate service response, which discoverServerURLs already fetches. Date is CORS-safelisted, so it reads cross-origin with no change on M-Lab's side. ndt7.js reads that header, returns it as ServerTime alongside the URLs and hands it to the download/upload complete callbacks. It is null when the header is missing or unparseable, and on the config.server path, which never contacts the locate service. measurement-client stores it on the record as serverTimestamp, preferring the download leg, and upload.service sends it as server_timestamp (ISO 8601, or null). The existing timestamp field is untouched: this is an extra reference point, not a replacement. Backend counterpart: unicef/giga-meter-backend PR adding the server_timestamp column. Co-Authored-By: Claude Opus 5 --- .../measurement-client.service.spec.ts | 65 +++++++++++++++++++ .../services/measurement-client.service.ts | 13 ++++ src/app/services/upload.service.spec.ts | 49 ++++++++++++++ src/app/services/upload.service.ts | 6 ++ src/assets/js/ndt/ndt7.js | 44 +++++++++++++ 5 files changed, 177 insertions(+) diff --git a/src/app/services/measurement-client.service.spec.ts b/src/app/services/measurement-client.service.spec.ts index 1d26111d..eef05deb 100644 --- a/src/app/services/measurement-client.service.spec.ts +++ b/src/app/services/measurement-client.service.spec.ts @@ -4,6 +4,27 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { Network } from '@awesome-cordova-plugins/network/ngx'; import { MeasurementClientService } from './measurement-client.service'; +/** + * Minimal shape of a finished ndt7 run: just the fields finalizeMeasurement + * and calculateDataUsage read. `ServerTime` is what ndt7.js now reports from + * the Date header of the locate response. + */ +const resultsWith = (s2cServerTime: any, c2sServerTime: any) => ({ + 'NDTResult.S2C': { + ServerTime: s2cServerTime, + LastServerMeasurement: { + ConnectionInfo: { UUID: 'ndt-abcde_1591240104_00000000000042C7' }, + TCPInfo: { BytesAcked: 10, BytesReceived: 20 }, + }, + }, + 'NDTResult.C2S': { + ServerTime: c2sServerTime, + LastServerMeasurement: { + TCPInfo: { BytesAcked: 30, BytesReceived: 40 }, + }, + }, +}); + describe('MeasurementClientService', () => { let service: MeasurementClientService; let httpMock: HttpTestingController; @@ -17,9 +38,53 @@ describe('MeasurementClientService', () => { ] }); service = TestBed.inject(MeasurementClientService); + // uploadEnabled = false keeps finalizeMeasurement off the network. + spyOn(service['settingsService'], 'get').and.returnValue(false); + spyOn(service['historyService'], 'add').and.stub(); + spyOn(service['sharedService'], 'broadcast').and.stub(); }); it('should be created', () => { expect(service).toBeTruthy(); }); + + describe('serverTimestamp', () => { + it('takes the server clock reported by the download leg', async () => { + const serverTime = Date.UTC(2026, 7, 24, 10, 30, 0); + const record: any = { + timestamp: 1, + results: resultsWith(serverTime, serverTime), + }; + + await service['finalizeMeasurement'](record); + + expect(record.serverTimestamp).toBe(serverTime); + }); + + it('falls back to the upload leg when the download leg has none', async () => { + const serverTime = Date.UTC(2026, 7, 24, 10, 30, 0); + const record: any = { + timestamp: 1, + results: resultsWith(undefined, serverTime), + }; + + await service['finalizeMeasurement'](record); + + expect(record.serverTimestamp).toBe(serverTime); + }); + + it('stays null when neither leg reports one', async () => { + const record: any = { timestamp: 1, results: resultsWith(null, null) }; + + await service['finalizeMeasurement'](record); + + expect(record.serverTimestamp).toBeNull(); + }); + + it('is initialized to null on a fresh record', () => { + const record: any = service['initializeMeasurementRecord']('manual'); + + expect(record.serverTimestamp).toBeNull(); + }); + }); }); diff --git a/src/app/services/measurement-client.service.ts b/src/app/services/measurement-client.service.ts index 578e99d6..760c76a8 100644 --- a/src/app/services/measurement-client.service.ts +++ b/src/app/services/measurement-client.service.ts @@ -166,6 +166,9 @@ export class MeasurementClientService { wifiConnections: null, scheduledSlot: scheduleContext?.slot ?? null, scheduledAt: scheduleContext?.scheduledAt ?? null, + // Wall clock reported by M-Lab, filled in by finalizeMeasurement. Null + // when the locate service did not return a usable Date header. + serverTimestamp: null, }; } @@ -310,6 +313,16 @@ export class MeasurementClientService { .ConnectionInfo.UUID || ''; measurementRecord.version = 1; + // `timestamp` above comes from Date.now(), so it is only as good as the + // clock of the machine running the test. ndt7 reports the locate service's + // own clock alongside each completed leg; keep it so the backend can tell + // the two apart. Both legs carry the same value - fall back to the upload + // leg only in case the download one is missing. + measurementRecord.serverTimestamp = + measurementRecord.results['NDTResult.S2C']?.ServerTime ?? + measurementRecord.results['NDTResult.C2S']?.ServerTime ?? + null; + const dataUsage = this.calculateDataUsage(measurementRecord.results); measurementRecord.dataUsage = dataUsage; diff --git a/src/app/services/upload.service.spec.ts b/src/app/services/upload.service.spec.ts index 60a02dbe..ba39c470 100644 --- a/src/app/services/upload.service.spec.ts +++ b/src/app/services/upload.service.spec.ts @@ -1,10 +1,12 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { of } from 'rxjs'; import { UploadService } from './upload.service'; describe('UploadService', () => { let service: UploadService; + let httpMock: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ @@ -12,9 +14,56 @@ describe('UploadService', () => { providers: [provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()] }); service = TestBed.inject(UploadService); + httpMock = TestBed.inject(HttpTestingController); }); it('should be created', () => { expect(service).toBeTruthy(); }); + + describe('server_timestamp', () => { + /** + * Drives uploadMeasurement far enough to inspect the POST body, with + * makeMeasurement stubbed so the test does not need a full ndt7 result. + */ + const post = (record: any): any => { + service['settingService'].currentSettings = { uploadEnabled: true } as any; + spyOn(service['settingService'], 'get').and.returnValue(''); + spyOn(service['storage'], 'get').and.returnValue(''); + spyOn(service['hardwareIdService'], 'getHardwareId').and.returnValue(null); + spyOn(service['locationService'], 'fetchAndSaveGeolocation').and.returnValue(of(null)); + spyOn(service['locationService'], 'saveGeolocation').and.stub(); + spyOn(service['posthog'], 'capture').and.stub(); + // makeMeasurement is where `ts` (the device clock) normally gets set, + // so the fake has to keep doing that for the rest of the method to run. + spyOn(service, 'makeMeasurement').and.callFake((r: any) => { + service.ts = new Date(r.timestamp); + return { + ClientInfo: { Country: 'TZ', IP: '10.0.0.1' }, + Notes: 'manual', + } as any; + }); + + service.uploadMeasurement(record).subscribe(); + const req = httpMock.expectOne((r) => r.url.endsWith('measurements')); + req.flush({}); + return req.request.body; + }; + + afterEach(() => httpMock.verify()); + + it('sends the ndt7 server clock as an ISO string', () => { + const serverTimestamp = Date.UTC(2026, 7, 24, 10, 30, 0); + + const body = post({ Notes: 'manual', timestamp: Date.now(), serverTimestamp }); + + expect(body['server_timestamp']).toBe(new Date(serverTimestamp).toISOString()); + }); + + it('sends null when ndt7 could not read a server clock', () => { + const body = post({ Notes: 'manual', timestamp: Date.now(), serverTimestamp: null }); + + expect(body['server_timestamp']).toBeNull(); + }); + }); }); diff --git a/src/app/services/upload.service.ts b/src/app/services/upload.service.ts index 4fb977f2..a13b7cb5 100644 --- a/src/app/services/upload.service.ts +++ b/src/app/services/upload.service.ts @@ -184,6 +184,12 @@ export class UploadService { : null; measurement['upload_failed'] = false; + // M-Lab's own clock at server discovery. Independent of the device clock, + // which on these machines is often wrong; null when ndt7 could not read it. + measurement['server_timestamp'] = record.serverTimestamp + ? new Date(record.serverTimestamp).toISOString() + : null; + // Add API key if configured. if (apiKey != '') { diff --git a/src/assets/js/ndt/ndt7.js b/src/assets/js/ndt/ndt7.js index 50aa50bf..a8da244d 100644 --- a/src/assets/js/ndt/ndt7.js +++ b/src/assets/js/ndt/ndt7.js @@ -42,6 +42,36 @@ throw new Error(err); }; + /** + * serverTimeFromResponse reads the server's wall-clock time out of the + * Date header of a fetch response, as epoch milliseconds. + * + * The ndt7 protocol itself carries no wall clock: the measurement messages + * the server sends during the test only have ElapsedTime, which is + * relative to the start of the test, and the browser WebSocket API does + * not expose the headers of the handshake response. The locate service + * request is the one place a real server clock is reachable, and Date is a + * CORS-safelisted response header, so it can be read cross-origin without + * the service having to opt in via Access-Control-Expose-Headers. + * + * @param {Response} response - The fetch response to read the header from. + * @return {?number} Epoch milliseconds, or null when the header is absent + * or unparseable. + * + * This function is not exported. + */ + const serverTimeFromResponse = function (response) { + if (!response || !response.headers) { + return null; + } + const header = response.headers.get("Date"); + if (!header) { + return null; + } + const parsed = Date.parse(header); + return isNaN(parsed) ? null : parsed; + }; + /** * discoverServerURLs contacts a web service (likely the Measurement Lab * locate service, but not necessarily) and gets URLs with access tokens in @@ -85,6 +115,9 @@ return { "///ndt/v7/download": downloadURL.toString(), "///ndt/v7/upload": uploadURL.toString(), + // No locate request happens on this path, so there is no server + // clock to report. + ServerTime: null, }; } @@ -99,6 +132,7 @@ const response = await fetch(lbURL).catch((err) => { throw new Error(err); }); + const serverTime = serverTimeFromResponse(response); const js = await response.json(); if (!("results" in js)) { callbacks.error(`Could not understand response from ${lbURL}: ${js}`); @@ -118,6 +152,10 @@ return { "///ndt/v7/download": choice.urls[protocol + ":///ndt/v7/download"], "///ndt/v7/upload": choice.urls[protocol + ":///ndt/v7/upload"], + // Wall-clock time reported by the locate service, taken a few seconds + // before the test starts. The workers ignore this key; it is read back + // in runNDT7Worker. + ServerTime: serverTime, }; } @@ -149,6 +187,9 @@ let clientMeasurement; let serverMeasurement; + // Stays null if server discovery never resolves (e.g. the worker times + // out first), so callers always get an explicit value. + let serverTime = null; // This makes the worker. The worker won't actually start until it // receives a message. @@ -163,6 +204,7 @@ callbacks.complete({ LastClientMeasurement: clientMeasurement, LastServerMeasurement: serverMeasurement, + ServerTime: serverTime, }); } worker.terminate(); @@ -219,6 +261,8 @@ throw err; }); + serverTime = typeof urls.ServerTime === "number" ? urls.ServerTime : null; + // Start the worker. worker.postMessage(urls); From 7603faaa8af1dbd3c5418c8fc1e1299ef4f7bc9a Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Tue, 25 Aug 2026 15:43:47 +0200 Subject: [PATCH 18/22] chore(research): translate probe strings and comments to English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe scripts mixed Spanish into console output, probe metadata and the CSV the tracking spreadsheet is built from. Translate all of it so the research artefacts are shareable with the wider team. Text-only change: console messages, `group`/`attr`/`requiresAdmin` labels, the availability/volatility values (`sí`/`parcial`/`vacío` -> `yes`/`partial`/ `empty`) and the CSV header. The row keys were renamed alongside their CSV consumer (`disponible`/`ejemplo`/`volatil`/`notas` -> `available`/`sample`/ `volatile`/`notes`). No logic changes. Note: the generated CSV/JSON columns and the `group` values change, so a table already pasted into the UNICEF spreadsheet needs regenerating. Co-Authored-By: Claude Opus 5 --- scripts/research/electron-probe-main.js | 4 +- scripts/research/probe-system-info.js | 178 ++++++++++++------------ 2 files changed, 91 insertions(+), 91 deletions(-) diff --git a/scripts/research/electron-probe-main.js b/scripts/research/electron-probe-main.js index 7c559946..5f5c2a16 100644 --- a/scripts/research/electron-probe-main.js +++ b/scripts/research/electron-probe-main.js @@ -1,5 +1,5 @@ /** - * electron-probe-main.js — Artefacto 2 del plan 0008. + * electron-probe-main.js — Artifact 2 of plan 0008. * * Runs probe-system-info.js inside an Electron MAIN PROCESS, so the calls run * on Electron's embedded Node (not the system Node) — the same runtime the @@ -24,7 +24,7 @@ app.whenReady().then(async () => { try { await main(); } catch (err) { - console.error('El probe falló dentro de Electron:', err); + console.error('The probe failed inside Electron:', err); exitCode = 1; } app.exit(exitCode); diff --git a/scripts/research/probe-system-info.js b/scripts/research/probe-system-info.js index 67abf944..64a342dd 100644 --- a/scripts/research/probe-system-info.js +++ b/scripts/research/probe-system-info.js @@ -47,8 +47,8 @@ function loadSysteminformation() { } } console.error( - 'No pude cargar "systeminformation". Corre el script desde el repo o haz\n' + - '`npm i systeminformation@^5` en la carpeta donde copiaste este archivo.' + 'Could not load "systeminformation". Run the script from the repo, or run\n' + + '`npm i systeminformation@^5` in the folder where you copied this file.' ); process.exit(1); } @@ -73,7 +73,7 @@ function runPowershellJson(psCommand) { try { resolve(JSON.parse(text)); } catch (parseErr) { - reject(new Error('PowerShell devolvió algo no-JSON: ' + text.slice(0, 200))); + reject(new Error('PowerShell returned something non-JSON: ' + text.slice(0, 200))); } } ); @@ -120,24 +120,24 @@ function inferVpn(interfaces, routes) { // --------------------------------------------------------------------------- function buildProbes() { return [ - // --- Red: interfaces / gateway / uso / conexiones --- + // --- Network: interfaces / gateway / usage / connections --- { - group: 'red-interfaces', - attr: 'interfaces (ip4/ip6, tipo, MAC, velocidad, virtual, dhcp, dns)', + group: 'network-interfaces', + attr: 'interfaces (ip4/ip6, type, MAC, speed, virtual, dhcp, dns)', call: 'si.networkInterfaces()', requiresAdmin: 'no', fn: () => si.networkInterfaces(), }, { - group: 'red-gateway', - attr: 'gateway por defecto', + group: 'network-gateway', + attr: 'default gateway', call: 'si.networkGatewayDefault()', requiresAdmin: 'no', fn: () => si.networkGatewayDefault(), }, { - group: 'red-uso', - attr: 'bytes rx/tx y tasa (2 muestras)', + group: 'network-usage', + attr: 'rx/tx bytes and rate (2 samples)', call: `si.networkStats() x2 (${NETSTATS_SAMPLE_GAP_MS} ms)`, requiresAdmin: 'no', fn: async () => { @@ -160,10 +160,10 @@ function buildProbes() { }, }, { - group: 'red-conexiones', - attr: 'conexiones activas (coste alto, evaluar)', + group: 'network-connections', + attr: 'active connections (expensive, to be evaluated)', call: 'si.networkConnections()', - requiresAdmin: 'parcial (PID/proceso solo elevado)', + requiresAdmin: 'partial (PID/process only when elevated)', fn: async () => { const connections = await si.networkConnections(); // Full list is huge and privacy-heavy; keep counts + a small sample. @@ -177,32 +177,32 @@ function buildProbes() { }; }, }, - // --- Red: Wi-Fi --- + // --- Network: Wi-Fi --- { - group: 'red-wifi', - attr: 'Wi-Fi conectada (ya en uso por el app)', + group: 'network-wifi', + attr: 'connected Wi-Fi (already used by the app)', call: 'si.wifiConnections()', requiresAdmin: 'no', fn: () => si.wifiConnections(), }, { - group: 'red-wifi', - attr: 'redes Wi-Fi visibles (scan real)', + group: 'network-wifi', + attr: 'visible Wi-Fi networks (real scan)', call: 'si.wifiNetworks()', - requiresAdmin: 'no (requiere servicio WLAN activo)', + requiresAdmin: 'no (requires the WLAN service running)', fn: () => si.wifiNetworks(), }, { - group: 'red-wifi', - attr: 'adaptadores Wi-Fi', + group: 'network-wifi', + attr: 'Wi-Fi adapters', call: 'si.wifiInterfaces()', requiresAdmin: 'no', fn: () => si.wifiInterfaces(), }, - // --- Red: DNS --- + // --- Network: DNS --- { - group: 'red-dns', - attr: 'servidores DNS configurados', + group: 'network-dns', + attr: 'configured DNS servers', call: 'Get-DnsClientServerAddress (PowerShell)', requiresAdmin: 'no', fn: async () => { @@ -212,10 +212,10 @@ function buildProbes() { return result; }, }, - // --- Red: VPN --- + // --- Network: VPN --- { - group: 'red-vpn', - attr: 'detección de VPN (inferencia)', + group: 'network-vpn', + attr: 'VPN detection (inference)', call: 'si.networkInterfaces() + Get-NetRoute 0.0.0.0/0', requiresAdmin: 'no', fn: async () => { @@ -231,24 +231,24 @@ function buildProbes() { return inferVpn(Array.isArray(interfaces) ? interfaces : [interfaces], routes); }, }, - // --- Sistema --- + // --- System --- { - group: 'sistema-os', - attr: 'OS (build, edición, arquitectura, hypervisor)', + group: 'system-os', + attr: 'OS (build, edition, architecture, hypervisor)', call: 'si.osInfo()', requiresAdmin: 'no', fn: () => si.osInfo(), }, { - group: 'sistema-cpu', - attr: 'CPU modelo/núcleos/velocidad', + group: 'system-cpu', + attr: 'CPU model/cores/speed', call: 'si.cpu()', requiresAdmin: 'no', fn: () => si.cpu(), }, { - group: 'sistema-cpu', - attr: 'carga actual de CPU', + group: 'system-cpu', + attr: 'current CPU load', call: 'si.currentLoad()', requiresAdmin: 'no', fn: async () => { @@ -264,45 +264,45 @@ function buildProbes() { }, }, { - group: 'sistema-cpu', - attr: 'temperatura de CPU', + group: 'system-cpu', + attr: 'CPU temperature', call: 'si.cpuTemperature()', - requiresAdmin: 'probable (WMI/ACPI suele requerir elevación)', + requiresAdmin: 'likely (WMI/ACPI usually requires elevation)', fn: () => si.cpuTemperature(), }, - // --- Disco / memoria --- + // --- Disk / memory --- { - group: 'sistema-disco', - attr: 'discos físicos (tipo HDD/SSD, tamaño)', + group: 'system-disk', + attr: 'physical disks (HDD/SSD type, size)', call: 'si.diskLayout()', requiresAdmin: 'no', fn: () => si.diskLayout(), }, { - group: 'sistema-disco', - attr: 'filesystems (tamaño/usado/libre)', + group: 'system-disk', + attr: 'filesystems (size/used/free)', call: 'si.fsSize()', requiresAdmin: 'no', fn: () => si.fsSize(), }, { - group: 'sistema-memoria', - attr: 'memoria total/libre/usada', + group: 'system-memory', + attr: 'memory total/free/used', call: 'si.mem()', requiresAdmin: 'no', fn: () => si.mem(), }, - // --- Instalación / entorno de ejecución --- + // --- Installation / runtime environment --- { - group: 'sistema-instalacion', - attr: 'proceso corre elevado', + group: 'system-installation', + attr: 'process runs elevated', call: 'fltmc.exe (exit code)', requiresAdmin: 'no', fn: async () => ({ elevated: isProcessElevated() }), }, { - group: 'sistema-instalacion', - attr: 'entorno de ejecución (Node, usuario, hostname)', + group: 'system-installation', + attr: 'runtime environment (Node, user, hostname)', call: 'os.userInfo() / process.version', requiresAdmin: 'no', fn: async () => ({ @@ -310,7 +310,7 @@ function buildProbes() { hostname: os.hostname(), username: os.userInfo().username, windowsRelease: os.release(), - note: 'app.getAppPath() y fecha de instalación: verificar en Electron (Artefacto 2)', + note: 'app.getAppPath() and install date: verify in Electron (Artifact 2)', }), }, ]; @@ -332,7 +332,7 @@ async function runPass(probes) { } const ms = Number(process.hrtime.bigint() - startedAt) / 1e6; results.push({ ...probe, fn: undefined, value, error, ms: Math.round(ms) }); - const status = error ? 'ERROR' : isEmptyValue(value) ? 'vacío' : 'ok'; + const status = error ? 'ERROR' : isEmptyValue(value) ? 'empty' : 'ok'; console.log(` ${probe.call.padEnd(50)} ${String(Math.round(ms)).padStart(6)} ms ${status}`); } return results; @@ -349,7 +349,7 @@ function isEmptyValue(value) { return value === ''; } -/** 'sí' | 'no' | 'parcial' for the CSV. */ +/** 'yes' | 'no' | 'partial' for the CSV. */ function availability(entry) { if (entry.error) return 'no'; if (isEmptyValue(entry.value)) return 'no'; @@ -361,9 +361,9 @@ function availability(entry) { if (typeof node === 'object') return Object.values(node).forEach(walk); flatValues.push(node); })(value); - if (flatValues.length === 0) return 'parcial'; // structure exists but all values null/empty + if (flatValues.length === 0) return 'partial'; // structure exists but all values null/empty const emptyish = flatValues.filter((v) => v === '' || v === null || v === -1).length; - return emptyish > flatValues.length / 2 ? 'parcial' : 'sí'; + return emptyish > flatValues.length / 2 ? 'partial' : 'yes'; } // --------------------------------------------------------------------------- @@ -431,15 +431,15 @@ function sampleValue(entry) { function buildCsv(rows) { const header = [ - 'grupo', - 'atributo', - 'llamada', - 'disponible', - 'valor de ejemplo (redactado)', + 'group', + 'attribute', + 'call', + 'available', + 'sample value (redacted)', 'ms', - 'requiere admin', - 'volátil', - 'notas', + 'requires admin', + 'volatile', + 'notes', ]; const lines = [header.map(csvEscape).join(',')]; for (const row of rows) { @@ -448,12 +448,12 @@ function buildCsv(rows) { row.group, row.attr, row.call, - row.disponible, - row.ejemplo, + row.available, + row.sample, row.ms, row.requiresAdmin, - row.volatil, - row.notas, + row.volatile, + row.notes, ] .map(csvEscape) .join(',') @@ -472,20 +472,20 @@ async function main() { const baseName = `probe-${hostname}-${runtime}-${timestamp}`; const elevated = isProcessElevated(); - console.log(`\nProbe de red/sistema — plan 0008 (release v2.0.4)`); + console.log(`\nNetwork/system probe — plan 0008 (release v2.0.4)`); console.log( - `Equipo: ${hostname} | Node ${process.version}` + + `Machine: ${hostname} | Node ${process.version}` + (process.versions.electron ? ` (Electron ${process.versions.electron}, main process)` : '') + - ` | elevado: ${elevated ? 'sí' : 'no'}` + ` | elevated: ${elevated ? 'yes' : 'no'}` ); - console.log(`\nPasada 1/2:`); + console.log(`\nPass 1/2:`); const probes = buildProbes(); const pass1 = await runPass(probes); - console.log(`\nEsperando ${PASS_DELAY_MS / 1000}s para la pasada de volatilidad…`); + console.log(`\nWaiting ${PASS_DELAY_MS / 1000}s for the volatility pass…`); await sleep(PASS_DELAY_MS); - console.log(`\nPasada 2/2:`); + console.log(`\nPass 2/2:`); const pass2 = await runPass(buildProbes()); const rows = pass1.map((entry, i) => { @@ -493,20 +493,20 @@ async function main() { const changed = !entry.error && !second.error && JSON.stringify(entry.value) !== JSON.stringify(second.value); const notes = []; - if (entry.error) notes.push('falló en pasada 1'); - if (second.error && !entry.error) notes.push('falló solo en pasada 2 (inestable)'); + if (entry.error) notes.push('failed on pass 1'); + if (second.error && !entry.error) notes.push('failed only on pass 2 (unstable)'); if (Math.max(entry.ms, second.ms) > 1000) - notes.push(`lento (peor pasada: ${Math.max(entry.ms, second.ms)} ms)`); + notes.push(`slow (worst pass: ${Math.max(entry.ms, second.ms)} ms)`); return { group: entry.group, attr: entry.attr, call: entry.call, - disponible: availability(entry), - ejemplo: sampleValue(entry), + available: availability(entry), + sample: sampleValue(entry), ms: Math.round((entry.ms + second.ms) / 2), requiresAdmin: entry.requiresAdmin, - volatil: changed ? 'sí' : 'no', - notas: notes.join('; '), + volatile: changed ? 'yes' : 'no', + notes: notes.join('; '), }; }); @@ -536,27 +536,27 @@ async function main() { // Console summary: failures + worst timings. const failures = pass1.filter((entry) => entry.error); const slowest = [...pass1].sort((a, b) => b.ms - a.ms).slice(0, 5); - console.log('\n================ RESUMEN ================'); - console.log(`Atributos probados: ${pass1.length} | fallos: ${failures.length}`); - for (const failure of failures) console.log(` FALLO ${failure.call}: ${failure.error}`); - console.log('Llamadas más lentas (pasada 1):'); + console.log('\n================ SUMMARY ================'); + console.log(`Attributes probed: ${pass1.length} | failures: ${failures.length}`); + for (const failure of failures) console.log(` FAILED ${failure.call}: ${failure.error}`); + console.log('Slowest calls (pass 1):'); for (const entry of slowest) console.log(` ${String(entry.ms).padStart(6)} ms ${entry.call}`); - console.log('\nArchivos generados:'); + console.log('\nGenerated files:'); console.log(` ${rawPath}`); console.log(` ${redactedPath}`); console.log(` ${csvPath}`); console.log( - '\nAVISO: el JSON crudo contiene SSIDs, MACs, IPs internas y el usuario de\n' + - 'Windows. NO lo compartas fuera del equipo sin revisarlo; adjunta al\n' + - 'ticket/spreadsheet la versión -redacted.json y el CSV.' + '\nWARNING: the raw JSON contains SSIDs, MACs, internal IPs and the Windows\n' + + 'username. Do NOT share it outside the team without reviewing it first; attach\n' + + 'the -redacted.json version and the CSV to the ticket/spreadsheet.' ); } // Run directly (`node probe-system-info.js`) or require it from an Electron -// main process (Artefacto 2 del plan 0008) and await `main()` there. +// main process (Artifact 2 of plan 0008) and await `main()` there. if (require.main === module) { main().catch((err) => { - console.error('El probe terminó con un error no controlado:', err); + console.error('The probe finished with an unhandled error:', err); process.exit(1); }); } else { From e2f9f189a8830a4d3b93c601f279a700788e75b6 Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Wed, 26 Aug 2026 10:55:53 +0200 Subject: [PATCH 19/22] feat: capture network/device context and diagnose empty Wi-Fi reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills the device columns the backend has accepted since giga-meter-backend#353 but that nothing ever populated — every row landed with NULL device_name, device_model, device_manufacturer, app_build_number and sdk_version — and adds the network context and Wi-Fi diagnosis from research plan 0008. Main process: - `device-context.ts` captures the volatile context the ticket asked for and no column covered: DNS servers, default gateway, connection type, VPN inference, IP family, rx/tx counters, CPU load, available memory and free disk. - The `get-wifi-connections` handler now explains an empty result. On Windows 11 24H2+ `netsh wlan` returns nothing while Location services are off, so the list arrives empty on a machine that IS on Wi-Fi. The handler classifies why (no_adapter | wlan_service_off | location_disabled | unknown, from a registry read plus adapter and service checks) and recovers the SSID through the ungated Get-NetConnectionProfile fallback, tagging it `ssid_source: 'nlm'`. Both extra calls only run on the empty path. - `get-device-identity` returns hostname, model and manufacturer, cached per app run. Cost. The research measured networkInterfaces (~1.1 s), cpu (~1.7 s) and diskLayout (~2.1 s) — far too slow per measurement — so everything derived from them is computed once and cached under the default gateway, which recomputes when the machine changes network. The per-measurement calls are the cheap ones and run concurrently, keeping the added time inside the 1.5 s budget the plan set. Every capture fails soft: a locked-down PowerShell policy yields null fields, never a failed measurement. `app_build_number` is the short commit, baked by generate-build-mode.js (which already runs before every Electron build), overridable with GIGA_METER_BUILD_NUMBER for a pipeline that has its own id, falling back to the app version outside a git checkout. Neither package.json carries a build counter, so the commit is the only value that distinguishes two builds of one version. The same generator now also bakes the speed-test SDK versions so `sdk_version` cannot drift from the dependency that shipped. Renderer: `DeviceContextService` wraps the IPC calls, failing soft outside Electron so the web build and the unit tests keep working; the measurement client captures the context before the test and the upload service maps it onto the payload. Queued offline measurements carry the fields automatically — the whole built payload is what goes into IndexedDB. Note: the attribute list is the research proposal, still pending Vipul's confirmation against the tracking spreadsheet. Co-Authored-By: Claude Opus 5 --- electron/scripts/generate-build-mode.js | 68 ++- electron/src/device-context.ts | 392 ++++++++++++++++++ electron/src/index.ts | 112 ++++- electron/src/preload.ts | 3 + .../services/device-context.service.spec.ts | 213 ++++++++++ src/app/services/device-context.service.ts | 178 ++++++++ .../measurement-client.service.spec.ts | 24 +- .../services/measurement-client.service.ts | 59 ++- src/app/services/upload.service.ts | 20 + 9 files changed, 1048 insertions(+), 21 deletions(-) create mode 100644 electron/src/device-context.ts create mode 100644 src/app/services/device-context.service.spec.ts create mode 100644 src/app/services/device-context.service.ts diff --git a/electron/scripts/generate-build-mode.js b/electron/scripts/generate-build-mode.js index 5f6a179c..cb340344 100644 --- a/electron/scripts/generate-build-mode.js +++ b/electron/scripts/generate-build-mode.js @@ -14,6 +14,7 @@ */ const fs = require('fs'); const path = require('path'); +const { execFileSync } = require('child_process'); const VALID_MODES = ['prod', 'dev', 'stg']; @@ -52,8 +53,60 @@ function detectMode() { return 'prod'; } +/** + * Short commit the build came from, reported to the backend as + * `app_build_number`. The repo has no build counter — package.json and + * electron/package.json both just repeat the app version — so the commit is the + * only value that actually distinguishes two builds of the same version. + * + * 1) GIGA_METER_BUILD_NUMBER wins, for a CI pipeline that has its own build id. + * 2) Otherwise the short git hash, which covers local and CI builds made from a + * checkout. + * 3) Otherwise null, and the app falls back to the app version at runtime. + */ +function detectBuildCommit() { + const override = (process.env.GIGA_METER_BUILD_NUMBER || '').trim(); + if (override) return override; + + try { + return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { + cwd: __dirname, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch (_) { + // Not a git checkout (e.g. building from an exported tarball). + return null; + } +} + +/** + * Versions of the speed-test SDKs that ship in this build, reported as + * `sdk_version` on the measurement. Read from the root package.json so the value + * cannot drift from the dependency that actually ran; the caret is stripped + * because a row should record a version, not a range. + */ +function detectSdkVersions() { + const PKG_FILE = path.resolve(__dirname, '../../package.json'); + const clean = (range) => + typeof range === 'string' ? range.replace(/^[\^~>=<\s]+/, '') : null; + + try { + const deps = JSON.parse(fs.readFileSync(PKG_FILE, 'utf8')).dependencies || {}; + return { + mlab: clean(deps['@m-lab/ndt7']), + cloudflare: clean(deps['@cloudflare/speedtest']), + }; + } catch (err) { + console.warn(`[build-mode] could not read ${PKG_FILE}: ${err.message}`); + return { mlab: null, cloudflare: null }; + } +} + const mode = detectMode(); const autoUpdateEnabled = mode === 'prod'; +const buildCommit = detectBuildCommit(); +const sdkVersions = detectSdkVersions(); const content = `// AUTO-GENERATED by electron/scripts/generate-build-mode.js — do not edit by hand. // Derived from src/environments/_environment.prod.ts (\`mode\`) or the APP_MODE env var. @@ -61,6 +114,18 @@ export const BUILD_MODE: 'prod' | 'dev' | 'stg' = '${mode}'; // Auto-update is only active in production builds. stg/dev builds ship without it. export const AUTO_UPDATE_ENABLED = ${autoUpdateEnabled}; + +// Short commit this build came from, or null when built outside a git checkout. +// Uploaded as \`app_build_number\`; the app falls back to the app version when null. +export const BUILD_COMMIT: string | null = ${ + buildCommit ? `'${buildCommit}'` : 'null' +}; + +// Speed-test SDK versions bundled in this build, reported as \`sdk_version\`. +export const SDK_VERSIONS: { mlab: string | null; cloudflare: string | null } = { + mlab: ${sdkVersions.mlab ? `'${sdkVersions.mlab}'` : 'null'}, + cloudflare: ${sdkVersions.cloudflare ? `'${sdkVersions.cloudflare}'` : 'null'}, +}; `; // Only write when the content actually changes. The live-runner watches @@ -78,5 +143,6 @@ if (current !== content) { } console.log( - `[build-mode] mode="${mode}" -> AUTO_UPDATE_ENABLED=${autoUpdateEnabled} (${OUT_FILE})` + `[build-mode] mode="${mode}" -> AUTO_UPDATE_ENABLED=${autoUpdateEnabled}, ` + + `BUILD_COMMIT=${buildCommit ?? "null"} (${OUT_FILE})` ); diff --git a/electron/src/device-context.ts b/electron/src/device-context.ts new file mode 100644 index 00000000..2811e83a --- /dev/null +++ b/electron/src/device-context.ts @@ -0,0 +1,392 @@ +/** + * Network and device context captured next to a measurement (research plan 0008). + * + * Two things live here: + * + * 1. `getDeviceNetworkInformation()` — the volatile per-measurement context the + * ticket asked for and that no column covered: DNS, default gateway, + * connection type, VPN inference, IP family, rx/tx bytes, plus the cheap + * performance context around the test (CPU load, free memory, free disk). + * + * 2. `classifyWifiUnavailable()` / `getSsidFromNlm()` — the diagnosis for the + * finding that motivated the research: on Windows 11 24H2+ the WLAN stack is + * gated behind the Location services permission, so `si.wifiConnections()` + * comes back EMPTY on a machine that is connected over Wi-Fi. The app cannot + * prompt its way out (WinRT returns Denied with no dialog while the master + * toggle is off), but it can say *why* the data is missing, and it can still + * read the SSID through the ungated Network Location Manager profile. + * + * Cost discipline. The research measured every call on a real Windows machine: + * `networkInterfaces` (~1100 ms), `cpu` (~1700 ms) and `diskLayout` (~2100 ms) + * are far too expensive to run per measurement, so everything derived from them + * is computed once and cached, keyed on the default gateway so that moving to a + * different network recomputes it. The per-measurement calls are the cheap ones + * and they run concurrently, which keeps the added wall-clock well inside the + * 1.5 s budget the plan set. + * + * Every capture fails soft: a blocked PowerShell policy or a missing adapter + * yields a null field, never a thrown error — a measurement must never fail + * because the diagnostics could not be read. + */ + +import { execFile } from 'child_process'; +import * as si from 'systeminformation'; + +/** Volatile context stored as `device_network_information` on the measurement. */ +export interface DeviceNetworkInformation { + connection_type?: string; + default_gateway?: string; + dns_servers?: string[]; + ip_family?: string; + vpn_likely?: boolean; + vpn_adapter?: string; + link_speed_mbps?: number; + net_bytes_rx?: number; + net_bytes_tx?: number; + cpu_load_percent?: number; + memory_available_mb?: number; + disk_free_mb?: number; +} + +/** Why `wifi_connections` came back empty. Mirrors the backend's whitelist. */ +export type WifiUnavailableReason = + | 'no_adapter' + | 'wlan_service_off' + | 'location_disabled' + | 'unknown'; + +const EXEC_TIMEOUT_MS = 10000; + +/** VPN heuristic: virtual adapters plus well-known VPN driver/interface names. */ +const VPN_NAME_PATTERN = + /(tap|tun|wintun|wireguard|openvpn|anyconnect|cisco|zerotier|tailscale|nordlynx|hamachi|fortissl|fortinet|globalprotect|pangp|juniper|pulse|l2tp|sstp|ikev2)/i; + +const BYTES_PER_MB = 1024 * 1024; + +function run(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + command, + args, + { timeout: EXEC_TIMEOUT_MS, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, + (err, stdout) => (err ? reject(err) : resolve(String(stdout))) + ); + }); +} + +function runPowershellJson(psCommand: string): Promise { + return run('powershell.exe', [ + '-NoProfile', + '-NonInteractive', + '-Command', + `${psCommand} | ConvertTo-Json -Depth 4 -Compress`, + ]).then((stdout) => { + const text = stdout.trim(); + if (!text) return null; + return JSON.parse(text); + }); +} + +/** Resolves to null instead of rejecting, so one blocked call cannot sink the rest. */ +async function soft(label: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + console.warn(`[device-context] ${label} unavailable:`, error?.message ?? error); + return null; + } +} + +function toMb(bytes: unknown): number | undefined { + return typeof bytes === 'number' && Number.isFinite(bytes) + ? Math.round(bytes / BYTES_PER_MB) + : undefined; +} + +function round(value: unknown, decimals = 1): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined; + const factor = 10 ** decimals; + return Math.round(value * factor) / factor; +} + +// --------------------------------------------------------------------------- +// Expensive, slow-moving half: derived from networkInterfaces + DNS, cached. +// --------------------------------------------------------------------------- + +interface CachedNetworkShape { + gateway: string | null; + connection_type?: string; + ip_family?: string; + vpn_likely?: boolean; + vpn_adapter?: string; + link_speed_mbps?: number; + dns_servers?: string[]; + /** Name of the wireless adapter, used by the Wi-Fi diagnosis below. */ + wirelessAlias?: string; + hasWirelessAdapter: boolean; +} + +let cachedShape: CachedNetworkShape | null = null; + +function pickDefaultInterface(interfaces: si.Systeminformation.NetworkInterfacesData[]) { + return ( + interfaces.find((iface) => iface.default) ?? + interfaces.find((iface) => iface.operstate === 'up' && !iface.internal && iface.ip4) ?? + null + ); +} + +function inferConnectionType(iface: si.Systeminformation.NetworkInterfacesData | null) { + if (!iface) return undefined; + if (iface.type === 'wireless') return 'wifi'; + if (iface.type === 'wired') return 'ethernet'; + return 'unknown'; +} + +function inferIpFamily(iface: si.Systeminformation.NetworkInterfacesData | null) { + if (!iface) return undefined; + const hasV4 = Boolean(iface.ip4); + const hasV6 = Boolean(iface.ip6); + if (hasV4 && hasV6) return 'dual'; + if (hasV4) return 'v4'; + if (hasV6) return 'v6'; + return undefined; +} + +function inferVpn(interfaces: si.Systeminformation.NetworkInterfacesData[]) { + const candidate = interfaces.find( + (iface) => + iface.operstate === 'up' && + (iface.virtual === true || + VPN_NAME_PATTERN.test(iface.ifaceName || '') || + VPN_NAME_PATTERN.test(iface.iface || '')) + ); + return { + vpn_likely: Boolean(candidate), + vpn_adapter: candidate ? candidate.ifaceName || candidate.iface : undefined, + }; +} + +/** + * DNS servers of the active interfaces. + * + * `si.networkInterfaces()` does not expose them on Windows, so this shells out to + * PowerShell (~1 s in the research runs) — which is exactly why it sits on the + * cached side and never in the per-measurement path. + */ +async function readDnsServers(): Promise { + const result = await soft('DNS servers', () => + runPowershellJson( + 'Get-DnsClientServerAddress -AddressFamily IPv4 | ' + + 'Where-Object {$_.ServerAddresses} | Select-Object -ExpandProperty ServerAddresses' + ) + ); + if (!result) return undefined; + const list = (Array.isArray(result) ? result : [result]) + .filter((item): item is string => typeof item === 'string' && item !== '') + // Loopback entries are the local resolver stub, not a configured server. + .filter((item) => !item.startsWith('127.')); + return list.length > 0 ? Array.from(new Set(list)) : undefined; +} + +/** + * The slow-moving half of the context, recomputed only when the default gateway + * changes — i.e. when the machine moves to a different network. + */ +async function getNetworkShape(gateway: string | null): Promise { + if (cachedShape && cachedShape.gateway === gateway) { + return cachedShape; + } + + const interfaces = (await soft('network interfaces', () => si.networkInterfaces())) ?? []; + const list = Array.isArray(interfaces) ? interfaces : [interfaces]; + const active = pickDefaultInterface(list); + const wireless = list.find((iface) => iface.type === 'wireless'); + const { vpn_likely, vpn_adapter } = inferVpn(list); + + cachedShape = { + gateway, + connection_type: inferConnectionType(active), + ip_family: inferIpFamily(active), + vpn_likely, + vpn_adapter, + link_speed_mbps: + active && typeof active.speed === 'number' && active.speed > 0 + ? active.speed + : undefined, + dns_servers: await readDnsServers(), + wirelessAlias: wireless ? wireless.ifaceName || wireless.iface : undefined, + hasWirelessAdapter: Boolean(wireless), + }; + + return cachedShape; +} + +/** Drops the internal bookkeeping before the shape goes into the payload. */ +function shapeToPayload(shape: CachedNetworkShape): Partial { + return { + connection_type: shape.connection_type, + ip_family: shape.ip_family, + vpn_likely: shape.vpn_likely, + vpn_adapter: shape.vpn_adapter, + link_speed_mbps: shape.link_speed_mbps, + dns_servers: shape.dns_servers, + }; +} + +// --------------------------------------------------------------------------- +// Per-measurement capture +// --------------------------------------------------------------------------- + +/** + * Captures the volatile network/system context for one measurement. + * + * The cheap calls run concurrently: they are independent I/O, and serialising + * them is what would push the capture past the 1.5 s budget. + */ +export async function getDeviceNetworkInformation(): Promise { + const [gateway, stats, load, memory, disks] = await Promise.all([ + soft('default gateway', () => si.networkGatewayDefault()), + soft('network stats', () => si.networkStats()), + soft('cpu load', () => si.currentLoad()), + soft('memory', () => si.mem()), + soft('filesystems', () => si.fsSize()), + ]); + + const shape = await getNetworkShape(gateway || null); + + const primaryStats = Array.isArray(stats) ? stats[0] : stats; + // Free space on the volume the OS lives on; a machine with several volumes + // would otherwise report whichever one happened to come back first. + const systemDisk = Array.isArray(disks) + ? disks.find((fs) => /^[a-z]:/i.test(fs.mount) && fs.mount.toUpperCase().startsWith('C')) ?? + disks[0] + : null; + + const context: DeviceNetworkInformation = { + ...shapeToPayload(shape), + default_gateway: gateway || undefined, + net_bytes_rx: primaryStats?.rx_bytes ?? undefined, + net_bytes_tx: primaryStats?.tx_bytes ?? undefined, + cpu_load_percent: round(load?.currentLoad), + memory_available_mb: toMb(memory?.available), + disk_free_mb: toMb(systemDisk?.available), + }; + + // Undefined keys would serialise as absent anyway, but stripping them keeps the + // stored Json to the fields that were actually readable on this machine. + Object.keys(context).forEach((key) => { + if (context[key] === undefined) delete context[key]; + }); + + return context; +} + +// --------------------------------------------------------------------------- +// Wi-Fi unavailability diagnosis +// --------------------------------------------------------------------------- + +/** + * Reads one `CapabilityAccessManager\ConsentStore\location` value. + * + * HKLM is the system-wide Location master toggle; HKCU\...\NonPackaged is the + * per-user permission that covers desktop (unpackaged) apps such as this one. + * Either being off is enough to blank the WLAN stack. + * + * @returns the raw value ('Allow' / 'Deny'), or null when the key is unreadable. + */ +async function readLocationConsent(hive: 'HKLM' | 'HKCU'): Promise { + const key = + hive === 'HKLM' + ? 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\location' + : 'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\location\\NonPackaged'; + + const stdout = await soft(`${hive} location consent`, () => + run('reg.exe', ['query', key, '/v', 'Value']) + ); + if (!stdout) return null; + + const match = stdout.match(/Value\s+REG_SZ\s+(\S+)/i); + return match ? match[1] : null; +} + +/** True when the WLAN AutoConfig service is running. */ +async function isWlanServiceRunning(): Promise { + const stdout = await soft('WlanSvc state', () => run('sc.exe', ['query', 'WlanSvc'])); + if (!stdout) return null; + return /STATE\s+:\s+4\s+RUNNING/i.test(stdout); +} + +/** + * Explains an empty `wifiConnections()` result. + * + * Order matters: a machine with no wireless adapter is not a permission problem, + * and a stopped WLAN service is not one either — only once both are ruled out + * does the Location toggle become the answer. + */ +export async function classifyWifiUnavailable(): Promise { + // The Wi-Fi read happens before the context capture in the measurement flow, so + // on a blocked machine this is usually what populates the cache. Resolve the + // real gateway first (~200 ms) so the shape is cached under the right key and + // the capture that follows reuses it instead of recomputing the ~2 s of + // interface + DNS lookups. + const shape = + cachedShape ?? + (await getNetworkShape( + (await soft('default gateway', () => si.networkGatewayDefault())) || null + )); + if (!shape.hasWirelessAdapter) { + return 'no_adapter'; + } + + const wlanRunning = await isWlanServiceRunning(); + if (wlanRunning === false) { + return 'wlan_service_off'; + } + + const [machine, user] = await Promise.all([ + readLocationConsent('HKLM'), + readLocationConsent('HKCU'), + ]); + const blocked = [machine, user].some( + (value) => typeof value === 'string' && value.toLowerCase() !== 'allow' + ); + if (blocked) { + return 'location_disabled'; + } + + return 'unknown'; +} + +/** + * The connected SSID as the Network Location Manager knows it. + * + * NLM stores the profile name of the network the adapter is on, and — unlike + * `netsh wlan` — it is not gated behind the Location permission, so this still + * answers on a machine where the WLAN stack has gone silent. It only yields the + * name: BSSID, RSSI, channel and the neighbour scan have no ungated equivalent. + */ +export async function getSsidFromNlm(): Promise { + const shape = cachedShape; + const profiles = await soft('NLM connection profile', () => + runPowershellJson( + 'Get-NetConnectionProfile | Select-Object Name, InterfaceAlias, IPv4Connectivity' + ) + ); + if (!profiles) return null; + + const list = Array.isArray(profiles) ? profiles : [profiles]; + const match = + (shape?.wirelessAlias && + list.find((profile) => profile?.InterfaceAlias === shape.wirelessAlias)) || + list.find((profile) => /wi-?fi|wireless|wlan/i.test(String(profile?.InterfaceAlias ?? ''))) || + list[0]; + + const name = match?.Name; + return typeof name === 'string' && name.trim() !== '' ? name.trim() : null; +} + +/** Test seam: drops the cached slow-moving half. */ +export function resetDeviceContextCache(): void { + cachedShape = null; +} diff --git a/electron/src/index.ts b/electron/src/index.ts index edd62723..9eb68b5f 100644 --- a/electron/src/index.ts +++ b/electron/src/index.ts @@ -21,7 +21,17 @@ import { getIsQuiting, } from './setup'; import { captureException } from '@sentry/node'; -import { AUTO_UPDATE_ENABLED, BUILD_MODE } from './build-mode'; +import { + AUTO_UPDATE_ENABLED, + BUILD_COMMIT, + BUILD_MODE, + SDK_VERSIONS, +} from './build-mode'; +import { + classifyWifiUnavailable, + getDeviceNetworkInformation, + getSsidFromNlm, +} from './device-context'; // Set userData path to use name instead of productName - must be set before app is ready const userDataPath = path.join(app.getPath('appData'), 'unicef-pdca'); @@ -423,17 +433,41 @@ ipcMain.handle('get-installed-path', async () => { } }); -// IPC handler to get WiFi connections from renderer process +// IPC handler to get WiFi connections from renderer process. +// +// On Windows 11 24H2+ `netsh wlan` — which systeminformation wraps — returns +// nothing while the Location services toggle is off, so this comes back EMPTY on a +// machine that is connected over Wi-Fi (research plan 0008). When that happens the +// handler says why, and recovers the SSID through the ungated NLM profile so the +// row is not left with no network name at all. Both extra calls only run on the +// empty path, so a healthy machine pays nothing for them. ipcMain.handle('get-wifi-connections', async () => { try { console.log('📤 [Electron] WiFi connections requested via IPC'); const wifiConnections = await si.wifiConnections(); - console.log( - '✅ [Electron] WiFi connections returned via IPC:', - wifiConnections + if (Array.isArray(wifiConnections) && wifiConnections.length > 0) { + console.log( + '✅ [Electron] WiFi connections returned via IPC:', + wifiConnections + ); + return { wifiConnections, ssidSource: 'wlan' }; + } + + const wifiUnavailableReason = await classifyWifiUnavailable(); + const fallbackSsid = await getSsidFromNlm(); + console.warn( + `⚠️ [Electron] WiFi connections empty (${wifiUnavailableReason}); ` + + `NLM SSID fallback: ${fallbackSsid ?? 'none'}` ); - return { wifiConnections }; + + return { + wifiConnections, + wifiUnavailableReason, + // Only claim the NLM source when it actually produced a name. + ssidSource: fallbackSsid ? 'nlm' : undefined, + fallbackSsid, + }; } catch (error) { console.error( '❌ [Electron] Error getting WiFi connections via IPC:', @@ -444,6 +478,72 @@ ipcMain.handle('get-wifi-connections', async () => { } }); +// IPC handler for the volatile network/system context stored alongside the +// measurement (research plan 0008). Never throws: a machine where PowerShell or +// the registry is locked down returns whatever fields it could read. +ipcMain.handle('get-device-network-information', async () => { + try { + console.log('📤 [Electron] Device network information requested via IPC'); + const deviceNetworkInformation = await getDeviceNetworkInformation(); + + console.log( + '✅ [Electron] Device network information returned via IPC:', + deviceNetworkInformation + ); + return { deviceNetworkInformation }; + } catch (error) { + console.error( + '❌ [Electron] Error getting device network information via IPC:', + error + ); + captureException(error); + return { error: error.message }; + } +}); + +// IPC handler for the device identity columns the backend already accepts +// (device_name / device_model / device_manufacturer) plus the build number. +// These barely move, so systeminformation is only asked once per app run. +let cachedDeviceIdentity: { + deviceName: string; + deviceModel: string; + deviceManufacturer: string; + appBuildNumber: string; + sdkVersions: { mlab: string | null; cloudflare: string | null }; +} | null = null; + +ipcMain.handle('get-device-identity', async () => { + try { + if (cachedDeviceIdentity) { + return cachedDeviceIdentity; + } + console.log('📤 [Electron] Device identity requested via IPC'); + const systemData = await si.system(); + + cachedDeviceIdentity = { + deviceName: os.hostname(), + deviceModel: systemData.model, + deviceManufacturer: systemData.manufacturer, + // The commit the build came from; falls back to the app version when the + // build ran outside a git checkout (see generate-build-mode.js). + appBuildNumber: BUILD_COMMIT ?? app.getVersion(), + // Both are shipped; the renderer picks the one matching the protocol that + // actually ran, which it only knows after the test. + sdkVersions: SDK_VERSIONS, + }; + + console.log( + '✅ [Electron] Device identity returned via IPC:', + cachedDeviceIdentity + ); + return cachedDeviceIdentity; + } catch (error) { + console.error('❌ [Electron] Error getting device identity via IPC:', error); + captureException(error); + return { error: error.message }; + } +}); + // IPC handler to get hardware ID from renderer process ipcMain.handle('get-hardware-id', async () => { try { diff --git a/electron/src/preload.ts b/electron/src/preload.ts index bd5d1993..f2a4acca 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -26,6 +26,9 @@ contextBridge.exposeInMainWorld('electronAPI', { getWindowsUsername: () => ipcRenderer.invoke('get-windows-username'), getInstalledPath: () => ipcRenderer.invoke('get-installed-path'), getWifiConnections: () => ipcRenderer.invoke('get-wifi-connections'), + getDeviceNetworkInformation: () => + ipcRenderer.invoke('get-device-network-information'), + getDeviceIdentity: () => ipcRenderer.invoke('get-device-identity'), onHardwareId: (callback: (data: any) => void) => { ipcRenderer.on('system-hardware-id', (event, data) => callback(data)); }, diff --git a/src/app/services/device-context.service.spec.ts b/src/app/services/device-context.service.spec.ts new file mode 100644 index 00000000..c1a86d58 --- /dev/null +++ b/src/app/services/device-context.service.spec.ts @@ -0,0 +1,213 @@ +import { TestBed } from '@angular/core/testing'; +import { DeviceContextService } from './device-context.service'; +import { environment } from '../../environments/environment'; + +describe('DeviceContextService', () => { + let service: DeviceContextService; + + const setElectronAPI = (api: any) => { + (window as any).electronAPI = api; + }; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(DeviceContextService); + spyOn(console, 'warn'); + spyOn(console, 'error'); + }); + + afterEach(() => { + delete (window as any).electronAPI; + }); + + describe('getDeviceIdentity', () => { + it('maps the Electron payload onto the backend column names', async () => { + setElectronAPI({ + getDeviceIdentity: () => + Promise.resolve({ + deviceName: 'SCHOOL-PC-01', + deviceModel: 'ThinkPad E14', + deviceManufacturer: 'LENOVO', + appBuildNumber: 'a1b2c3d', + }), + }); + + expect(await service.getDeviceIdentity()).toEqual({ + device_name: 'SCHOOL-PC-01', + device_model: 'ThinkPad E14', + device_manufacturer: 'LENOVO', + app_build_number: 'a1b2c3d', + }); + }); + + it('falls back to the app version when the build has no commit', async () => { + setElectronAPI({ + getDeviceIdentity: () => + Promise.resolve({ + deviceName: 'SCHOOL-PC-01', + deviceModel: 'ThinkPad E14', + deviceManufacturer: 'LENOVO', + appBuildNumber: null, + }), + }); + + const identity = await service.getDeviceIdentity(); + + expect(identity.app_build_number).toBe(environment.app_version); + }); + + it('asks Electron only once', async () => { + const getDeviceIdentity = jasmine + .createSpy('getDeviceIdentity') + .and.returnValue(Promise.resolve({ deviceName: 'PC' })); + setElectronAPI({ getDeviceIdentity }); + + await service.getDeviceIdentity(); + await service.getDeviceIdentity(); + + expect(getDeviceIdentity).toHaveBeenCalledTimes(1); + }); + + it('returns nulls outside Electron instead of throwing', async () => { + expect(await service.getDeviceIdentity()).toEqual({ + device_name: null, + device_model: null, + device_manufacturer: null, + app_build_number: null, + }); + }); + + it('returns nulls when the handler reports an error', async () => { + setElectronAPI({ + getDeviceIdentity: () => Promise.resolve({ error: 'boom' }), + }); + + const identity = await service.getDeviceIdentity(); + + expect(identity.device_name).toBeNull(); + expect(console.warn).toHaveBeenCalled(); + }); + + it('returns nulls when the handler rejects', async () => { + setElectronAPI({ + getDeviceIdentity: () => Promise.reject(new Error('ipc down')), + }); + + const identity = await service.getDeviceIdentity(); + + expect(identity.device_model).toBeNull(); + expect(console.error).toHaveBeenCalled(); + }); + }); + + describe('getDeviceNetworkInformation', () => { + it('returns the context object', async () => { + const deviceNetworkInformation = { + connection_type: 'wifi', + default_gateway: '192.168.1.1', + vpn_likely: false, + }; + setElectronAPI({ + getDeviceNetworkInformation: () => + Promise.resolve({ deviceNetworkInformation }), + }); + + expect(await service.getDeviceNetworkInformation()).toEqual( + deviceNetworkInformation + ); + }); + + it('returns null for an empty context so the payload carries no key', async () => { + setElectronAPI({ + getDeviceNetworkInformation: () => + Promise.resolve({ deviceNetworkInformation: {} }), + }); + + expect(await service.getDeviceNetworkInformation()).toBeNull(); + }); + + it('returns null outside Electron', async () => { + expect(await service.getDeviceNetworkInformation()).toBeNull(); + }); + + it('returns null when the handler rejects', async () => { + setElectronAPI({ + getDeviceNetworkInformation: () => Promise.reject(new Error('nope')), + }); + + expect(await service.getDeviceNetworkInformation()).toBeNull(); + expect(console.error).toHaveBeenCalled(); + }); + }); + + describe('extractWifiDiagnostics', () => { + it('reports the WLAN source when the read succeeded', () => { + expect( + service.extractWifiDiagnostics({ + wifiConnections: [{ ssid: 'school-wifi' }], + ssidSource: 'wlan', + }) + ).toEqual({ + wifi_unavailable_reason: null, + ssid_source: 'wlan', + fallback_ssid: null, + }); + }); + + it('reports the reason and the NLM fallback when Location blocks the WLAN stack', () => { + expect( + service.extractWifiDiagnostics({ + wifiConnections: [], + wifiUnavailableReason: 'location_disabled', + ssidSource: 'nlm', + fallbackSsid: 'school-wifi', + }) + ).toEqual({ + wifi_unavailable_reason: 'location_disabled', + ssid_source: 'nlm', + fallback_ssid: 'school-wifi', + }); + }); + + it('reports nulls when the Wi-Fi read itself failed', () => { + expect(service.extractWifiDiagnostics({ error: 'boom' })).toEqual({ + wifi_unavailable_reason: null, + ssid_source: null, + fallback_ssid: null, + }); + expect(service.extractWifiDiagnostics(null)).toEqual({ + wifi_unavailable_reason: null, + ssid_source: null, + fallback_ssid: null, + }); + }); + }); + + describe('getSdkVersion', () => { + beforeEach(() => { + setElectronAPI({ + getDeviceIdentity: () => + Promise.resolve({ + sdkVersions: { mlab: '0.1.5', cloudflare: '1.4.1' }, + }), + }); + }); + + it('picks the SDK matching the protocol that ran', async () => { + expect(await service.getSdkVersion('mlab')).toBe('0.1.5'); + expect(await service.getSdkVersion('cloudflare')).toBe('1.4.1'); + expect(await service.getSdkVersion('Cloudflare')).toBe('1.4.1'); + }); + + it('defaults to the M-Lab SDK when no protocol is given', async () => { + expect(await service.getSdkVersion(null)).toBe('0.1.5'); + expect(await service.getSdkVersion(undefined)).toBe('0.1.5'); + }); + + it('returns null outside Electron', async () => { + delete (window as any).electronAPI; + + expect(await service.getSdkVersion('mlab')).toBeNull(); + }); + }); +}); diff --git a/src/app/services/device-context.service.ts b/src/app/services/device-context.service.ts new file mode 100644 index 00000000..f2d47a94 --- /dev/null +++ b/src/app/services/device-context.service.ts @@ -0,0 +1,178 @@ +import { Injectable } from '@angular/core'; +import { environment } from '../../environments/environment'; + +/** + * Device identity uploaded with every measurement. + * + * The backend has accepted these columns since giga-meter-backend#353, but + * nothing filled them, so every row landed with NULLs. `sdk_version` is resolved + * here rather than in the main process because it depends on which measurement + * protocol actually ran. + */ +export interface DeviceIdentity { + device_name: string | null; + device_model: string | null; + device_manufacturer: string | null; + app_build_number: string | null; +} + +/** Volatile network/system context; shape mirrors the backend whitelist. */ +export type DeviceNetworkInformation = Record; + +/** Why `wifi_connections` came back empty, and where the SSID came from. */ +export interface WifiDiagnostics { + wifi_unavailable_reason: string | null; + ssid_source: string | null; + fallback_ssid: string | null; +} + +/** + * Reads the network/device context the Windows client can see, via the Electron + * main process (research plan 0008). + * + * Everything here fails soft. The context is diagnostic metadata attached to a + * measurement — a school PC with a locked-down PowerShell policy or a stale + * Electron build must still be able to run and upload its test, just with null + * fields. No method rejects, and none of them are on the critical path. + */ +@Injectable({ + providedIn: 'root', +}) +export class DeviceContextService { + private cachedIdentity: DeviceIdentity | null = null; + + /** The Electron bridge, or null when running in a plain browser (ng serve, tests). */ + private get electronAPI(): any | null { + const api = (window as any)?.electronAPI; + return api ?? null; + } + + /** + * Machine identity. Cached for the lifetime of the app: the hostname and the + * hardware model do not change while the process is running, and the main + * process caches its half too. + */ + async getDeviceIdentity(): Promise { + if (this.cachedIdentity) { + return this.cachedIdentity; + } + + const empty: DeviceIdentity = { + device_name: null, + device_model: null, + device_manufacturer: null, + app_build_number: null, + }; + + const api = this.electronAPI; + if (!api?.getDeviceIdentity) { + // Older Electron shell, or the web build: nothing to read, and nothing to + // warn about on every measurement. + return empty; + } + + try { + const info = await api.getDeviceIdentity(); + if (!info || info.error) { + console.warn('[DeviceContext] device identity unavailable:', info?.error); + return empty; + } + + this.cachedIdentity = { + device_name: info.deviceName ?? null, + device_model: info.deviceModel ?? null, + device_manufacturer: info.deviceManufacturer ?? null, + app_build_number: info.appBuildNumber ?? environment.app_version ?? null, + }; + return this.cachedIdentity; + } catch (error) { + console.error('[DeviceContext] failed to read device identity:', error); + return empty; + } + } + + /** + * Volatile context for one measurement: gateway, DNS, connection type, VPN + * inference, IP family, rx/tx counters and the cheap performance readings. + * + * @returns the context object, or null when nothing could be read — so the + * payload carries no key rather than an empty object. + */ + async getDeviceNetworkInformation(): Promise { + const api = this.electronAPI; + if (!api?.getDeviceNetworkInformation) { + return null; + } + + try { + const result = await api.getDeviceNetworkInformation(); + if (!result || result.error) { + console.warn( + '[DeviceContext] network information unavailable:', + result?.error + ); + return null; + } + + const context = result.deviceNetworkInformation; + return context && Object.keys(context).length > 0 ? context : null; + } catch (error) { + console.error('[DeviceContext] failed to read network information:', error); + return null; + } + } + + /** + * Turns the Wi-Fi read into the diagnosis the backend stores. + * + * Takes the value the main process already returned for this measurement + * instead of asking again: `si.wifiConnections()` is the expensive part, and + * re-running it would double the cost of the one call that is already in the + * measurement path. + */ + extractWifiDiagnostics(wifiInfo: any): WifiDiagnostics { + if (!wifiInfo || wifiInfo.error) { + return { + wifi_unavailable_reason: null, + ssid_source: null, + fallback_ssid: null, + }; + } + + return { + wifi_unavailable_reason: wifiInfo.wifiUnavailableReason ?? null, + ssid_source: wifiInfo.ssidSource ?? null, + fallback_ssid: wifiInfo.fallbackSsid ?? null, + }; + } + + /** + * Version of the speed-test SDK that produced this measurement. + * + * Both versions are baked into the build from the root package.json by + * electron/scripts/generate-build-mode.js, so the value cannot drift from the + * dependency that actually shipped. Which one applies is only known after the + * test, because it depends on the protocol that ran. + */ + async getSdkVersion(protocol: string | null | undefined): Promise { + const api = this.electronAPI; + if (!api?.getDeviceIdentity) { + return null; + } + + try { + const info = await api.getDeviceIdentity(); + const versions = info?.sdkVersions; + if (!versions) return null; + + return ( + ((protocol ?? 'mlab').toLowerCase() === 'cloudflare' + ? versions.cloudflare + : versions.mlab) ?? null + ); + } catch (error) { + console.warn('[DeviceContext] could not resolve SDK version:', error); + return null; + } + } +} diff --git a/src/app/services/measurement-client.service.spec.ts b/src/app/services/measurement-client.service.spec.ts index 6c713498..ab75a306 100644 --- a/src/app/services/measurement-client.service.spec.ts +++ b/src/app/services/measurement-client.service.spec.ts @@ -48,13 +48,35 @@ describe('MeasurementClientService ndt7 package integration', () => { broadcast: jasmine.createSpy('broadcast'), on: jasmine.createSpy('on'), }; + // Device/network context is diagnostic metadata read over IPC; outside + // Electron it resolves to nulls, which is what these tests exercise. + const deviceContext: any = { + getDeviceIdentity: jasmine.createSpy('getDeviceIdentity').and.resolveTo({ + device_name: null, + device_model: null, + device_manufacturer: null, + app_build_number: null, + }), + getDeviceNetworkInformation: jasmine + .createSpy('getDeviceNetworkInformation') + .and.resolveTo(null), + getSdkVersion: jasmine.createSpy('getSdkVersion').and.resolveTo(null), + extractWifiDiagnostics: jasmine + .createSpy('extractWifiDiagnostics') + .and.returnValue({ + wifi_unavailable_reason: null, + ssid_source: null, + fallback_ssid: null, + }), + }; service = new MeasurementClientService( historyService, settingsService, networkService, uploadService, - sharedService + sharedService, + deviceContext ); spyOn(service, 'finalizeMeasurement').and.resolveTo(undefined); }); diff --git a/src/app/services/measurement-client.service.ts b/src/app/services/measurement-client.service.ts index 53b39186..08bbecc8 100644 --- a/src/app/services/measurement-client.service.ts +++ b/src/app/services/measurement-client.service.ts @@ -7,6 +7,7 @@ import { SettingsService } from './settings.service'; import { NetworkService } from './network.service'; import { UploadService } from './upload.service'; import { SharedService } from './shared-service.service'; +import { DeviceContextService } from './device-context.service'; @Injectable({ providedIn: 'root', @@ -83,7 +84,8 @@ export class MeasurementClientService { private settingsService: SettingsService, private networkService: NetworkService, private uploadService: UploadService, - private sharedService: SharedService + private sharedService: SharedService, + private deviceContext: DeviceContextService ) {} async runTest( @@ -108,10 +110,29 @@ export class MeasurementClientService { // Get Windows username, installed path, and WiFi connections const windowsUsername = await this.getWindowsUsername(); const installedPath = await this.getInstalledPath(); - const wifiConnections = await this.getWifiConnections(); + const wifiInfo = await this.getWifiInfo(); measurementRecord.windowsUsername = windowsUsername; measurementRecord.installedPath = installedPath; - measurementRecord.wifiConnections = wifiConnections; + measurementRecord.wifiConnections = wifiInfo?.wifiConnections ?? null; + + // Network/device context (research plan 0008). Captured before the test so + // the readings describe the machine as the test found it, and awaited + // together because they are independent I/O — serialising them is what would + // push the capture past the 1.5 s budget the plan set. + const [deviceIdentity, deviceNetworkInformation, sdkVersion] = + await Promise.all([ + this.deviceContext.getDeviceIdentity(), + this.deviceContext.getDeviceNetworkInformation(), + // This client is the ndt7 one, so the SDK that runs is always M-Lab's. + this.deviceContext.getSdkVersion('mlab'), + ]); + measurementRecord.deviceIdentity = deviceIdentity; + measurementRecord.deviceNetworkInformation = deviceNetworkInformation; + measurementRecord.sdkVersion = sdkVersion; + // Derived from the Wi-Fi read that already happened above, so the expensive + // wifiConnections() call is not repeated. + measurementRecord.wifiDiagnostics = + this.deviceContext.extractWifiDiagnostics(wifiInfo); try { measurementRecord.accessInformation = @@ -172,6 +193,10 @@ export class MeasurementClientService { windowsUsername: '', installedPath: '', wifiConnections: null, + deviceIdentity: null, + deviceNetworkInformation: null, + wifiDiagnostics: null, + sdkVersion: null, scheduledSlot: scheduleContext?.slot ?? null, scheduledAt: scheduleContext?.scheduledAt ?? null, }; @@ -490,29 +515,37 @@ export class MeasurementClientService { } /** - * Get WiFi connections from Electron process - * @returns WiFi connections array or null + * Get the full WiFi read from the Electron process. + * + * Returns the whole response rather than just the connections array: when the + * array comes back empty the main process also says why (`wifiUnavailableReason`) + * and may have recovered the SSID through the NLM fallback, and that diagnosis + * is uploaded with the measurement (research plan 0008). + * + * @returns the IPC response, or null when it is unavailable */ - private async getWifiConnections(): Promise { + private async getWifiInfo(): Promise { try { // Check if running in Electron if (window && (window as any).electronAPI) { console.log('📡 [WiFi Connections] Requesting WiFi connections...'); const wifiInfo = await (window as any).electronAPI.getWifiConnections(); - if (wifiInfo && wifiInfo.wifiConnections) { - console.log( - '✅ [WiFi Connections] Retrieved connections:', - wifiInfo.wifiConnections - ); - return wifiInfo.wifiConnections; - } else if (wifiInfo && wifiInfo.error) { + if (wifiInfo && wifiInfo.error) { console.error( '❌ [WiFi Connections] Error retrieving connections:', wifiInfo.error ); return null; } + + if (wifiInfo) { + console.log( + '✅ [WiFi Connections] Retrieved connections:', + wifiInfo.wifiConnections + ); + return wifiInfo; + } } else { console.log( '⚠️ [WiFi Connections] Not running in Electron, connections not available' diff --git a/src/app/services/upload.service.ts b/src/app/services/upload.service.ts index 4fb977f2..31686950 100644 --- a/src/app/services/upload.service.ts +++ b/src/app/services/upload.service.ts @@ -175,6 +175,26 @@ export class UploadService { measurement['installed_path'] = record.installedPath || null; measurement['wifi_connections'] = record.wifiConnections || null; + // Device identity. These columns have existed backend-side since + // giga-meter-backend#353 but nothing filled them, so every row landed NULL. + const identity = record.deviceIdentity || {}; + measurement['device_name'] = identity.device_name || null; + measurement['device_model'] = identity.device_model || null; + measurement['device_manufacturer'] = identity.device_manufacturer || null; + measurement['app_build_number'] = identity.app_build_number || null; + measurement['sdk_version'] = record.sdkVersion || null; + + // Network/device context and the Wi-Fi diagnosis (research plan 0008). + // On Windows 11 24H2+ an empty wifi_connections does not mean "no Wi-Fi": + // the WLAN stack is gated behind the Location permission, and these two + // fields are what let a query tell the two cases apart. + const wifiDiagnostics = record.wifiDiagnostics || {}; + measurement['wifi_unavailable_reason'] = + wifiDiagnostics.wifi_unavailable_reason || null; + measurement['ssid_source'] = wifiDiagnostics.ssid_source || null; + measurement['device_network_information'] = + record.deviceNetworkInformation || null; + // Schedule context: which slot/time this measurement was planned for // (null for manual runs). upload_failed flips to true only when the // realtime upload fails and the record is queued for later sync. From 621a30265b789ff230be767b4a6c06a7867edd2e Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Wed, 26 Aug 2026 14:16:05 +0200 Subject: [PATCH 20/22] docs: drop references to a private planning workspace The code comments pointed at plan numbers from a personal knowledge base that nobody outside its owner can open, so the references were dead weight in a public repo. Same facts, stated on their own. Co-Authored-By: Claude Opus 5 --- electron/src/device-context.ts | 6 +++--- electron/src/index.ts | 4 ++-- src/app/services/device-context.service.ts | 2 +- src/app/services/measurement-client.service.ts | 4 ++-- src/app/services/upload.service.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/electron/src/device-context.ts b/electron/src/device-context.ts index 2811e83a..bb58237b 100644 --- a/electron/src/device-context.ts +++ b/electron/src/device-context.ts @@ -1,5 +1,5 @@ /** - * Network and device context captured next to a measurement (research plan 0008). + * Network and device context captured next to a measurement. * * Two things live here: * @@ -16,7 +16,7 @@ * toggle is off), but it can say *why* the data is missing, and it can still * read the SSID through the ungated Network Location Manager profile. * - * Cost discipline. The research measured every call on a real Windows machine: + * Cost discipline. A probe measured every call on a real Windows machine: * `networkInterfaces` (~1100 ms), `cpu` (~1700 ms) and `diskLayout` (~2100 ms) * are far too expensive to run per measurement, so everything derived from them * is computed once and cached, keyed on the default gateway so that moving to a @@ -171,7 +171,7 @@ function inferVpn(interfaces: si.Systeminformation.NetworkInterfacesData[]) { * DNS servers of the active interfaces. * * `si.networkInterfaces()` does not expose them on Windows, so this shells out to - * PowerShell (~1 s in the research runs) — which is exactly why it sits on the + * PowerShell (~1 s when measured) — which is exactly why it sits on the * cached side and never in the per-measurement path. */ async function readDnsServers(): Promise { diff --git a/electron/src/index.ts b/electron/src/index.ts index 9eb68b5f..c9d3c2a4 100644 --- a/electron/src/index.ts +++ b/electron/src/index.ts @@ -437,7 +437,7 @@ ipcMain.handle('get-installed-path', async () => { // // On Windows 11 24H2+ `netsh wlan` — which systeminformation wraps — returns // nothing while the Location services toggle is off, so this comes back EMPTY on a -// machine that is connected over Wi-Fi (research plan 0008). When that happens the +// machine that is connected over Wi-Fi. When that happens the // handler says why, and recovers the SSID through the ungated NLM profile so the // row is not left with no network name at all. Both extra calls only run on the // empty path, so a healthy machine pays nothing for them. @@ -479,7 +479,7 @@ ipcMain.handle('get-wifi-connections', async () => { }); // IPC handler for the volatile network/system context stored alongside the -// measurement (research plan 0008). Never throws: a machine where PowerShell or +// measurement. Never throws: a machine where PowerShell or // the registry is locked down returns whatever fields it could read. ipcMain.handle('get-device-network-information', async () => { try { diff --git a/src/app/services/device-context.service.ts b/src/app/services/device-context.service.ts index f2d47a94..095a29e1 100644 --- a/src/app/services/device-context.service.ts +++ b/src/app/services/device-context.service.ts @@ -28,7 +28,7 @@ export interface WifiDiagnostics { /** * Reads the network/device context the Windows client can see, via the Electron - * main process (research plan 0008). + * main process. * * Everything here fails soft. The context is diagnostic metadata attached to a * measurement — a school PC with a locked-down PowerShell policy or a stale diff --git a/src/app/services/measurement-client.service.ts b/src/app/services/measurement-client.service.ts index 08bbecc8..11e72e21 100644 --- a/src/app/services/measurement-client.service.ts +++ b/src/app/services/measurement-client.service.ts @@ -115,7 +115,7 @@ export class MeasurementClientService { measurementRecord.installedPath = installedPath; measurementRecord.wifiConnections = wifiInfo?.wifiConnections ?? null; - // Network/device context (research plan 0008). Captured before the test so + // Network/device context. Captured before the test so // the readings describe the machine as the test found it, and awaited // together because they are independent I/O — serialising them is what would // push the capture past the 1.5 s budget the plan set. @@ -520,7 +520,7 @@ export class MeasurementClientService { * Returns the whole response rather than just the connections array: when the * array comes back empty the main process also says why (`wifiUnavailableReason`) * and may have recovered the SSID through the NLM fallback, and that diagnosis - * is uploaded with the measurement (research plan 0008). + * is uploaded with the measurement. * * @returns the IPC response, or null when it is unavailable */ diff --git a/src/app/services/upload.service.ts b/src/app/services/upload.service.ts index 31686950..fb9ebc3c 100644 --- a/src/app/services/upload.service.ts +++ b/src/app/services/upload.service.ts @@ -184,7 +184,7 @@ export class UploadService { measurement['app_build_number'] = identity.app_build_number || null; measurement['sdk_version'] = record.sdkVersion || null; - // Network/device context and the Wi-Fi diagnosis (research plan 0008). + // Network/device context and the Wi-Fi diagnosis. // On Windows 11 24H2+ an empty wifi_connections does not mean "no Wi-Fi": // the WLAN stack is gated behind the Location permission, and these two // fields are what let a query tell the two cases apart. From e16402e945ffe48d6d2cd48fec652aaa3a4ba41f Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Wed, 26 Aug 2026 14:39:46 +0200 Subject: [PATCH 21/22] chore: update version to 2.0.4 and enhance school registration handling - Bump version in package.json and package-lock.json for both Electron and main app to 2.0.4. - Introduce `isRegistering` flag in ConfirmschoolPage to prevent duplicate school registrations during the confirmation process. - Update confirmSchool method to handle registration state and prevent multiple submissions. - Add new Playwright test for verifying that multiple taps on the confirmation button do not create duplicate registrations. - Minor adjustments to loading behavior and button states in the UI to improve user experience during registration. --- e2e/playwright/db.ts | 7 + e2e/playwright/duplicate-registration.spec.ts | 188 ++++++++++++ electron/package-lock.json | 4 +- electron/package.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/app/confirmschool/confirmschool.page.html | 2 +- .../confirmschool/confirmschool.page.spec.ts | 167 ++++++++++- src/app/confirmschool/confirmschool.page.ts | 267 +++++++++--------- src/environments/environment.prod.ts | 2 +- src/environments/environment.ts | 2 +- 11 files changed, 503 insertions(+), 144 deletions(-) create mode 100644 e2e/playwright/duplicate-registration.spec.ts diff --git a/e2e/playwright/db.ts b/e2e/playwright/db.ts index 42d1d50a..1f3efb88 100644 --- a/e2e/playwright/db.ts +++ b/e2e/playwright/db.ts @@ -76,3 +76,10 @@ export function measurementCount(gigaIdSchool: string): number { `SELECT to_json(count(*)) FROM measurements WHERE giga_id_school = '${gigaIdSchool}';`, ); } + +/** Cuántos registros de dispositivo hay para la escuela del fixture. */ +export function schoolRegistrationCount(gigaIdSchool: string): number { + return queryJson( + `SELECT to_json(count(*)) FROM dailycheckapp_school WHERE giga_id_school = '${gigaIdSchool}';`, + ); +} diff --git a/e2e/playwright/duplicate-registration.spec.ts b/e2e/playwright/duplicate-registration.spec.ts new file mode 100644 index 00000000..758aa904 --- /dev/null +++ b/e2e/playwright/duplicate-registration.spec.ts @@ -0,0 +1,188 @@ +import { test, expect, Browser, Page } from '@playwright/test'; +import { schoolRegistrationCount } from './db'; + +// Regresión del bug de registros duplicados: la pantalla de confirmación creaba +// una fila en `dailycheckapp_school` (con su propio user_id) por cada tap en +// "Yes", porque el botón no tenía guard de reentrada y el loader se cerraba solo +// a los 4 s, dejando la pantalla aparentemente muerta mientras el registro +// seguía en curso. +// +// Fixtures: los mismos que el happy path (seed-spain, aplicado por el compose +// de e2e). Este spec crea su propia página, así que no depende del estado que +// deja happy-path.spec.ts — solo mide el delta de filas en la DB, que sí es +// compartido. +test.describe.configure({ mode: 'serial' }); + +const API = process.env.E2E_API ?? 'http://localhost:3000/api/v1/'; +const COUNTRY_NAME = process.env.E2E_COUNTRY ?? 'Spain'; +const SCHOOL_EXTERNAL_ID = process.env.E2E_SCHOOL_ID ?? 'ES-TEST-SCHOOL-01'; +const EXPECTED_GIGA_ID = + process.env.E2E_GIGA_ID ?? '11111111-1111-4111-8111-111111111111'; +const SKIP_DB = process.env.E2E_SKIP_DB === '1'; + +// Contra el backend local el POST de registro responde en milisegundos, así que +// sin retardo no habría ventana en la que tocar "Yes" otra vez y el test pasaría +// incluso con el bug presente. Este retardo simula la máquina lenta del reporte. +// Es mayor que los 4 s del auto-dismiss que tenía el loader, para que el spec +// también detecte si alguien vuelve a pasarle una `duration`. +const REGISTRATION_DELAY = 6_000; +const EXTRA_TAPS = 5; +const TAP_INTERVAL = 1_000; + +const FAKE_IP_INFO = { + ip: '83.56.0.10', + asn: 'AS3352', + as_name: 'Telefonica de Espana', + country: 'ES', + country_code: 'ES', + continent: 'EU', +}; +const FAKE_GEOJS = { + ip: '83.56.0.10', + country: 'Spain', + country_code: 'ES', + latitude: '40.4168', + longitude: '-3.7038', + organization_name: 'Telefonica de Espana', +}; +const FAKE_IP_METADATA = { + ip: '83.56.0.10', + hostname: 'e2e.local', + city: 'Madrid', + region: 'Madrid', + country: 'ES', + loc: '40.4168,-3.7038', + org: 'AS3352 Telefonica de Espana', + postal: '28001', + timezone: 'Europe/Madrid', +}; + +// Ionic deja las páginas anteriores en el DOM (ion-page-hidden): todo selector +// filtra por :visible o matchea copias ocultas. +function visibleButton(page: Page, text: string) { + return page.locator('ion-button:visible', { hasText: text }).first(); +} + +async function waitForLoaderGone(page: Page): Promise { + await page + .locator('ion-loading') + .first() + .waitFor({ state: 'attached', timeout: 2_000 }) + .catch(() => undefined); + await page.waitForFunction( + () => document.querySelectorAll('ion-loading').length === 0, + undefined, + { timeout: 30_000 }, + ); +} + +let page: Page; +let registrationPosts = 0; + +test.beforeAll(async ({ browser }: { browser: Browser }) => { + page = await browser.newPage(); + + await page.route('**/api.ipinfo.io/**', (route) => + route.fulfill({ json: FAKE_IP_INFO }), + ); + await page.route('**/ipv4.geojs.io/**', (route) => + route.fulfill({ json: FAKE_GEOJS }), + ); + await page.route('**/api/v1/ip-metadata/**', (route) => + route.fulfill({ json: FAKE_IP_METADATA }), + ); + + // Retardar el registro y contar cuántas veces se manda. El contador va aquí + // (y no en page.on('response')) para que cuente también los POST que se + // quedan en vuelo si el test termina antes de que respondan. + await page.route(`${API}dailycheckapp_schools`, async (route) => { + if (route.request().method() !== 'POST') { + await route.continue(); + return; + } + registrationPosts += 1; + await new Promise((resolve) => setTimeout(resolve, REGISTRATION_DELAY)); + await route.continue(); + }); + + // Silenciar el startup test (delay aleatorio de 0-15 min) para que no compita + // con el registro. + await page.addInitScript(() => { + const now = String(Date.now()); + localStorage.setItem('startupTestScheduled', now); + localStorage.setItem('lastStartupTest', now); + localStorage.setItem('lastMeasurement', now); + }); +}); + +test.afterAll(async () => { + await page?.close(); +}); + +test('tocar "Yes" varias veces registra la escuela una sola vez', async () => { + const rowsBefore = SKIP_DB ? null : schoolRegistrationCount(EXPECTED_GIGA_ID); + + // ── Registro hasta la pantalla de confirmación (pasos 1-5 del happy path) ── + await page.goto('/#/home'); + await waitForLoaderGone(page); + await visibleButton(page, 'Next').click(); + + await waitForLoaderGone(page); + await visibleButton(page, 'Next').click(); + await visibleButton(page, 'Next').click(); + await page.locator('ion-checkbox[name="privacy"]:visible').click(); + await visibleButton(page, 'Start Registration').click(); + + await waitForLoaderGone(page); + await page.locator('ion-searchbar input:visible').fill(COUNTRY_NAME); + await page + .locator('ion-item.dropdown_list:visible', { hasText: COUNTRY_NAME }) + .first() + .click(); + await page + .locator('ion-spinner:visible') + .waitFor({ state: 'hidden', timeout: 15_000 }) + .catch(() => undefined); + const confirmBtn = visibleButton(page, 'Confirm'); + await expect(confirmBtn).toBeEnabled(); + await confirmBtn.click(); + + await waitForLoaderGone(page); + await page.locator('input.searchTerm:visible').fill(SCHOOL_EXTERNAL_ID); + await visibleButton(page, 'Search ID').click(); + + await waitForLoaderGone(page); + await expect(page.locator('ion-item.single_school:visible')).toBeVisible(); + await visibleButton(page, 'Select').click(); + + // ── Usuario impaciente: un tap y cinco más mientras el POST está en vuelo ── + await waitForLoaderGone(page); + const yesBtn = page + .locator('ion-button.yesbtn:visible', { hasText: 'Yes' }) + .first(); + await yesBtn.click(); + + // El botón queda deshabilitado en cuanto arranca el registro. + await expect(yesBtn).toHaveAttribute('aria-disabled', 'true'); + + // dispatchEvent salta la comprobación de "clickable": así el test cubre el + // guard del componente y no solo el binding [disabled] del template. + for (let i = 0; i < EXTRA_TAPS; i++) { + await page.waitForTimeout(TAP_INTERVAL); + await yesBtn.dispatchEvent('click').catch(() => undefined); + } + + // ~5 s después del primer tap el loader sigue en pantalla: ya no se cierra + // solo a los 4 s mientras el registro sigue en vuelo. + await expect(page.locator('ion-loading').first()).toBeAttached(); + + await page.waitForURL('**/starttest', { timeout: 30_000 }); + + expect(registrationPosts).toBe(1); + + if (rowsBefore !== null) { + await expect + .poll(() => schoolRegistrationCount(EXPECTED_GIGA_ID), { timeout: 15_000 }) + .toBe(rowsBefore + 1); + } +}); diff --git a/electron/package-lock.json b/electron/package-lock.json index d97727c5..0279d8b9 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "unicef-pdca", - "version": "2.0.2", + "version": "2.0.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "unicef-pdca", - "version": "2.0.2", + "version": "2.0.4", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/electron/package.json b/electron/package.json index abf5ba2a..a9b08a37 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "unicef-pdca", - "version": "2.0.3", + "version": "2.0.4", "productName": "Giga Meter", "description": "Giga Meter", "author": { diff --git a/package-lock.json b/package-lock.json index 8d473719..8ab5aa7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "unicef-pdca", - "version": "2.0.3", + "version": "2.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "unicef-pdca", - "version": "2.0.3", + "version": "2.0.4", "dependencies": { "@angular/common": "^19.2.14", "@angular/core": "^19.2.14", diff --git a/package.json b/package.json index f70342c3..ee24d3c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "unicef-pdca", - "version": "2.0.3", + "version": "2.0.4", "productName": "Giga Meter", "author": "Giga-Unicef", "homepage": "https://projectconnect.unicef.org/about", diff --git a/src/app/confirmschool/confirmschool.page.html b/src/app/confirmschool/confirmschool.page.html index f2b6a6c7..0ef674d9 100644 --- a/src/app/confirmschool/confirmschool.page.html +++ b/src/app/confirmschool/confirmschool.page.html @@ -46,7 +46,7 @@
- +
diff --git a/src/app/confirmschool/confirmschool.page.spec.ts b/src/app/confirmschool/confirmschool.page.spec.ts index 9db8fa36..44fdb3b1 100644 --- a/src/app/confirmschool/confirmschool.page.spec.ts +++ b/src/app/confirmschool/confirmschool.page.spec.ts @@ -1,39 +1,192 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { IonicModule } from '@ionic/angular'; +import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { TranslateModule } from '@ngx-translate/core'; +import { Subject, of, throwError } from 'rxjs'; import { ConfirmschoolPage } from './confirmschool.page'; import { DatePipe } from '@angular/common'; +import { SchoolService } from '../services/school.service'; +import { LoadingService } from '../services/loading.service'; +import { NetworkService } from '../services/network.service'; +import { PosthogService } from '../services/posthog.service'; describe('ConfirmschoolPage', () => { let component: ConfirmschoolPage; let fixture: ComponentFixture; + let schoolService: jasmine.SpyObj; + let loading: jasmine.SpyObj; + let router: Router; + + /** Drain the microtask queue so the awaited lookups inside confirmSchool run. */ + const flushMicrotasks = async () => { + for (let i = 0; i < 25; i++) { + await Promise.resolve(); + } + }; beforeEach(waitForAsync(() => { + // The constructor reads the application language out of saved settings. + localStorage.setItem( + 'savedSettings', + JSON.stringify({ applicationLanguage: { code: 'en' } }) + ); + + schoolService = jasmine.createSpyObj('SchoolService', [ + 'registerSchoolDevice', + 'registerFlaggedSchool', + ]); + loading = jasmine.createSpyObj('LoadingService', ['present', 'dismiss']); + loading.present.and.resolveTo(undefined); + loading.dismiss.and.resolveTo(undefined); + TestBed.configureTestingModule({ - declarations: [ConfirmschoolPage], - imports: [IonicModule.forRoot(), + declarations: [ConfirmschoolPage], + imports: [ + IonicModule.forRoot(), RouterTestingModule, - TranslateModule.forRoot()], - providers: [ + TranslateModule.forRoot(), + ], + providers: [ DatePipe, + { provide: SchoolService, useValue: schoolService }, + { provide: LoadingService, useValue: loading }, + // NetworkService pulls in the Ionic Native Network plugin, which has no + // provider under TestBed; the page only holds the reference. + { provide: NetworkService, useValue: {} }, + { + provide: PosthogService, + useValue: jasmine.createSpyObj('PosthogService', [ + 'identify', + 'capture', + ]), + }, provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting() - ] -}).compileComponents(); + provideHttpClientTesting(), + ], + }).compileComponents(); fixture = TestBed.createComponent(ConfirmschoolPage); component = fixture.componentInstance; + router = TestBed.inject(Router); fixture.detectChanges(); + + // Route params are not provided by RouterTestingModule here; set the state + // confirmSchool() reads directly. + component.school = { + giga_id_school: '7f60b4e6-3a43-3f90-b7cb-882c3a6bdb80', + school_id: 'ext-1', + }; + component.schoolId = 'ext-1'; + component.selectedCountry = 'ES'; + component.detectedCountry = 'ES'; + component.selectedCountryName = 'Spain'; + + // The device/network lookups hit Electron IPC and a remote IP service; stub + // them so the spec exercises the registration flow only. + spyOn(component, 'getIPAddress').and.resolveTo('203.0.113.10'); + spyOn(component, 'getDeviceInfo').and.resolveTo({ + operatingSystem: 'windows', + } as any); + spyOn(component, 'getDeviceId').and.resolveTo({ identifier: 'device-1' }); + spyOn(component, 'getWindowsUsername').and.resolveTo('T001'); + spyOn(component, 'getInstalledPath').and.resolveTo('C:\\app'); + spyOn(component, 'getWifiConnections').and.resolveTo(null); + spyOn(router, 'navigate').and.resolveTo(true); })); it('should create', () => { expect(component).toBeTruthy(); }); + it('registers the device once and navigates to the test screen', async () => { + schoolService.registerSchoolDevice.and.returnValue(of('user-1')); + + await component.confirmSchool(); + + expect(schoolService.registerSchoolDevice).toHaveBeenCalledTimes(1); + expect(router.navigate).toHaveBeenCalledWith(['/starttest']); + expect(loading.dismiss).toHaveBeenCalled(); + }); + + it('presents the loader without a duration so it survives a slow registration', async () => { + schoolService.registerSchoolDevice.and.returnValue(of('user-1')); + + await component.confirmSchool(); + + expect(loading.present).toHaveBeenCalledWith( + jasmine.any(String), + undefined, + 'pdcaLoaderClass', + 'null' + ); + }); + + it('ignores repeat taps while a registration is in flight', async () => { + // Never emits: the first registration stays pending for the whole test. + schoolService.registerSchoolDevice.and.returnValue(new Subject()); + + const first = component.confirmSchool(); + await flushMicrotasks(); + await component.confirmSchool(); + await component.confirmSchool(); + await flushMicrotasks(); + + expect(schoolService.registerSchoolDevice).toHaveBeenCalledTimes(1); + expect(component.isRegistering).toBeTrue(); + expect(loading.dismiss).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + void first; + }); + + it('dismisses the loader, routes out and re-arms the button when registration fails', async () => { + schoolService.registerSchoolDevice.and.returnValue( + throwError(() => new Error('500')) + ); + + await component.confirmSchool(); + + expect(loading.dismiss).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith([ + 'schoolnotfound', + 'ext-1', + 'ES', + 'ES', + 'Spain', + ]); + expect(component.isRegistering).toBeFalse(); + }); + + it('dismisses the loader and re-arms the button when a pre-registration lookup fails', async () => { + (component.getWifiConnections as jasmine.Spy).and.rejectWith( + new Error('ipc down') + ); + schoolService.registerSchoolDevice.and.returnValue(of('user-1')); + + await component.confirmSchool(); + + expect(schoolService.registerSchoolDevice).not.toHaveBeenCalled(); + expect(loading.dismiss).toHaveBeenCalled(); + expect(component.isRegistering).toBeFalse(); + }); + + it('records a flagged school only when the selected country differs', async () => { + schoolService.registerSchoolDevice.and.returnValue(of('user-1')); + schoolService.registerFlaggedSchool.and.returnValue(of(1)); + + await component.confirmSchool(); + expect(schoolService.registerFlaggedSchool).not.toHaveBeenCalled(); + + component.isRegistering = false; + component.detectedCountry = 'PT'; + await component.confirmSchool(); + expect(schoolService.registerFlaggedSchool).toHaveBeenCalledTimes(1); + }); + afterEach(() => { + localStorage.clear(); TestBed.resetTestingModule(); }); }); diff --git a/src/app/confirmschool/confirmschool.page.ts b/src/app/confirmschool/confirmschool.page.ts index 56336a25..cc2334ba 100644 --- a/src/app/confirmschool/confirmschool.page.ts +++ b/src/app/confirmschool/confirmschool.page.ts @@ -1,8 +1,8 @@ -/* eslint-disable @typescript-eslint/no-unused-expressions */ /* eslint-disable @typescript-eslint/naming-convention */ import { Component, OnInit, ViewChild } from '@angular/core'; import { IonAccordionGroup } from '@ionic/angular'; import { ActivatedRoute, Router } from '@angular/router'; +import { firstValueFrom } from 'rxjs'; import { SchoolService } from '../services/school.service'; import { LoadingService } from '../services/loading.service'; import { StorageService } from '../services/storage.service'; @@ -35,6 +35,12 @@ export class ConfirmschoolPage implements OnInit{ detectedCountry: any; sub: any; appName = environment.appName; + /** + * Registration in flight. Guards `confirmSchool()` against repeat taps: every + * extra tap used to start an independent registration chain, and each one + * inserted another `dailycheckapp_school` row with a fresh `user_id`. + */ + isRegistering = false; constructor( private activatedroute: ActivatedRoute, public router: Router, @@ -87,10 +93,15 @@ export class ConfirmschoolPage implements OnInit{ } } - confirmSchool() { + async confirmSchool() { + /* One registration per confirmation: repeat taps are ignored while the + previous one is still in flight. */ + if (this.isRegistering) { + return; + } + this.isRegistering = true; + /* Store school id and giga id inside storage */ - let schoolData = {}; - let flaggedSchoolData = {}; const today = this.datePipe.transform( new Date(), 'yyyy-MM-ddah:mm:ssZZZZZ' @@ -98,134 +109,134 @@ export class ConfirmschoolPage implements OnInit{ const translatedText = this.translate.instant('searchCountry.loading'); const loadingMsg = `

${translatedText}

`; - this.loading.present(loadingMsg, 4000, 'pdcaLoaderClass', 'null'); - - // this.networkService.getAccessInformation().subscribe(c => { - this.getIPAddress().then((c) => { - this.getDeviceInfo().then((a) => { - this.getDeviceId().then(async(b) => { - // Get hardware ID for machine-level registration - const hardwareId = this.hardwareIdService.getHardwareId(); - - // Get Windows username, installed path, and WiFi connections - this.getWindowsUsername().then((windowsUsername) => { - this.getInstalledPath().then((installedPath) => { - this.getWifiConnections().then((wifiConnections) => { - schoolData = { - giga_id_school: this.school.giga_id_school, - mac_address: b.identifier, - os: a.operatingSystem, - app_version: environment.app_version, - created: today, - ip_address: c, // c.ip, - //country_code: c.country, - country_code: this.selectedCountry, - device_hardware_id: hardwareId || null, // Add hardware ID - windows_username: windowsUsername || null, // Add Windows username - installed_path: installedPath || null, // Add installed path - wifi_connections: wifiConnections || null, // Add WiFi connections - geolocation: this.locationService.getSavedGeolocation() - //school_id: this.school.school_id - }; - - // if(this.school.code === c.country){ - - this.schoolService - .registerSchoolDevice(schoolData) - .subscribe((response) => { - this.storage.set('deviceType', a.operatingSystem); - this.storage.set('macAddress', b.identifier); - this.storage.set('schoolUserId', response); - this.storage.set('schoolId', this.schoolId); - this.storage.set('gigaId', this.school.giga_id_school); - this.posthog.setSchool(this.school.giga_id_school); - this.storage.set('ip_address', c?.ip); - this.storage.set('version', environment.app_version); - //this.storage.set('country_code', c.country); - this.storage.set('country_code', this.selectedCountry); - this.storage.set('school_id', this.school.school_id); - this.storage.set('schoolInfo', JSON.stringify(this.school)); - - // Set first-time visit flags for new registration flow - this.storage.setFirstTimeVisit(true); - this.storage.setRegistrationCompleted(Date.now()); - - // A partir de aquí los eventos pertenecen a esta escuela. - this.posthog.identify(this.school.giga_id_school, { - country_code: this.selectedCountry, - }); - this.posthog.capture('registration_completed', { - country_code: this.selectedCountry, - }); - - this.loading.dismiss(); - - // Navigate to starttest page normally - this.router.navigate(['/starttest']).then(() => { - // Broadcast registration completion event after navigation - // This will trigger the first-time flow in StartTest component - this.sharedService.broadcast('registration:completed'); - }); - - this.settings.setSetting('scheduledTesting', true); - }), - (err) => { - this.loading.dismiss(); - this.router.navigate([ - 'schoolnotfound', - this.schoolId, - this.selectedCountry, - this.detectedCountry, - this.selectedCountryName, - ]); - /* Redirect to no result found page */ - }; - - if (this.selectedCountry !== this.detectedCountry) { - flaggedSchoolData = { - detected_country: this.detectedCountry, - selected_country: this.selectedCountry, - school_id: this.school.school_id, - created: today, - giga_id_school: this.school.giga_id_school, - }; - console.log('flagged', flaggedSchoolData); - this.schoolService - .registerFlaggedSchool(flaggedSchoolData) - .subscribe((response) => { - this.storage.set('detectedCountry', this.detectedCountry); - this.storage.set('selectedCountry', this.selectedCountry); - this.storage.set('schoolId', this.schoolId); - //this.loading.dismiss(); - // this.router.navigate(['/schoolsuccess']); - }), - (err) => { - this.loading.dismiss(); - //this.router.navigate(['schoolnotfound', this.schoolId, this.selectedCountry, this.detectedCountry]); - /* Redirect to no result found page */ - }; - } - - //} - //else{ - - // this.loading.dismiss(); - // this.router.navigate(['invalidlocation', - // this.schoolId, - // this.school.country, - // c.country + " (" +c.city + ")" - - // ]); - - //} - }); // Close getWifiConnections().then() - }); // Close getInstalledPath().then() - }); // Close getWindowsUsername().then() - }); + /* No duration: the loader stays up until the registration settles. With a + fixed duration it vanished after 4s while the flow was still running, and + the seemingly idle screen invited another tap. */ + this.loading.present(loadingMsg, undefined, 'pdcaLoaderClass', 'null'); + + try { + const ipAddress = await this.getIPAddress(); + const deviceInfo = await this.getDeviceInfo(); + const deviceId = await this.getDeviceId(); + // Get hardware ID for machine-level registration + const hardwareId = this.hardwareIdService.getHardwareId(); + // Get Windows username, installed path, and WiFi connections + const windowsUsername = await this.getWindowsUsername(); + const installedPath = await this.getInstalledPath(); + const wifiConnections = await this.getWifiConnections(); + + const schoolData = { + giga_id_school: this.school.giga_id_school, + mac_address: deviceId.identifier, + os: deviceInfo.operatingSystem, + app_version: environment.app_version, + created: today, + ip_address: ipAddress, + //country_code: c.country, + country_code: this.selectedCountry, + device_hardware_id: hardwareId || null, // Add hardware ID + windows_username: windowsUsername || null, // Add Windows username + installed_path: installedPath || null, // Add installed path + wifi_connections: wifiConnections || null, // Add WiFi connections + geolocation: this.locationService.getSavedGeolocation() + //school_id: this.school.school_id + }; + + /* Fire-and-forget, as before: the flagged record is independent of the + registration outcome and does not gate navigation. */ + if (this.selectedCountry !== this.detectedCountry) { + this.registerFlaggedSchool(today); + } + + const response = await firstValueFrom( + this.schoolService.registerSchoolDevice(schoolData) + ); + + this.storage.set('deviceType', deviceInfo.operatingSystem); + this.storage.set('macAddress', deviceId.identifier); + this.storage.set('schoolUserId', response); + this.storage.set('schoolId', this.schoolId); + this.storage.set('gigaId', this.school.giga_id_school); + this.posthog.setSchool(this.school.giga_id_school); + this.storage.set('ip_address', ipAddress?.ip); + this.storage.set('version', environment.app_version); + //this.storage.set('country_code', c.country); + this.storage.set('country_code', this.selectedCountry); + this.storage.set('school_id', this.school.school_id); + this.storage.set('schoolInfo', JSON.stringify(this.school)); + + // Set first-time visit flags for new registration flow + this.storage.setFirstTimeVisit(true); + this.storage.setRegistrationCompleted(Date.now()); + + // A partir de aquí los eventos pertenecen a esta escuela. + this.posthog.identify(this.school.giga_id_school, { + country_code: this.selectedCountry, }); + this.posthog.capture('registration_completed', { + country_code: this.selectedCountry, + }); + + // Navigate to starttest page normally + await this.router.navigate(['/starttest']); + // Broadcast registration completion event after navigation + // This will trigger the first-time flow in StartTest component + this.sharedService.broadcast('registration:completed'); + + this.settings.setSetting('scheduledTesting', true); + } catch (err) { + /* Registration failed, or the device/network lookups that precede it did. + Either way the screen must not hang: dismiss and route out so the user + can retry. */ + console.error('❌ [ConfirmSchool] Registration failed:', err); + this.router.navigate([ + 'schoolnotfound', + this.schoolId, + this.selectedCountry, + this.detectedCountry, + this.selectedCountryName, + ]); + /* Redirect to no result found page */ + } finally { + this.dismissLoader(); + this.isRegistering = false; + } + } + + /** + * Record a country mismatch (detected vs selected). Independent of the school + * registration: it never gates navigation. + */ + private registerFlaggedSchool(today: string) { + const flaggedSchoolData = { + detected_country: this.detectedCountry, + selected_country: this.selectedCountry, + school_id: this.school.school_id, + created: today, + giga_id_school: this.school.giga_id_school, + }; + console.log('flagged', flaggedSchoolData); + this.schoolService.registerFlaggedSchool(flaggedSchoolData).subscribe({ + next: () => { + this.storage.set('detectedCountry', this.detectedCountry); + this.storage.set('selectedCountry', this.selectedCountry); + this.storage.set('schoolId', this.schoolId); + }, + error: (err) => { + console.error('❌ [ConfirmSchool] Flagged school failed:', err); + }, }); } + /** + * Close the loader. The controller rejects when there is no overlay to + * dismiss (e.g. the flow finished before `present()` resolved), which is + * harmless here but would surface as an unhandled rejection. + */ + private dismissLoader() { + this.loading.dismiss().catch(() => undefined); + } + backToSaved(schoolObj) { this.router.navigate( [ diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index ad01fe7c..4721d77f 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -4,5 +4,5 @@ export const environment = { restAPI: 'https://uni-connect-services.azurewebsites.net/api/v1/', //restAPI: 'http://localhost:3000/api/v1/', token: env.token, - app_version: '2.0.3', + app_version: '2.0.4', }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index e9020e8b..a980de08 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -44,7 +44,7 @@ export const environment = { enableSessionRecording: (env as any).posthogEnableSessionRecording === true, }, - app_version: '2.0.3', + app_version: '2.0.4', appName: 'Giga Meter', appNameSuffix: '', showAboutMenu: true, From abd88cc8eb1b9c08437425faa6ee02f70851c4fb Mon Sep 17 00:00:00 2001 From: "Victor J. Lopez Roque" Date: Mon, 31 Aug 2026 13:26:23 +0200 Subject: [PATCH 22/22] feat: add Uzbek language support to environment configuration --- src/environments/environment.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/environments/environment.ts b/src/environments/environment.ts index a980de08..8ac0d801 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -80,5 +80,10 @@ export const environment = { label: 'Монгол', code: 'mn', }, + { + name: 'Uz', + label: "O'zbekcha", + code: 'uz', + }, ], };