// Local, synthetic demo accounts only. No credentials, locations or diary text are reported.
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { performance } from 'node:perf_hooks';
const origin = new URL(process.argv[2] || 'http://localhost:5188');
if (!['localhost', '127.0.0.1'].includes(origin.hostname) || !['http:', 'https:'].includes(origin.protocol))
throw new Error('This benchmark only supports a local EARSystem development server.');
const root = new URL('../', import.meta.url);
const credentials = (await readFile(new URL('src/EARSystem.Web/App_Data/demo-credentials.txt', root), 'utf8'))
.trim().split(/\r?\n/).map(line => line.split('\t')).filter(x => /^demo\d\d@ear\.local$/.test(x[0]));
if (credentials.length !== 20) throw new Error('Generate the 20 demo accounts first; see README.');
class Session {
jar = new Map();
token = '';
async request(path, options = {}) {
const headers = { ...options.headers, Cookie: [...this.jar].map(([k,v]) => k + '=' + v).join('; ') };
const response = await fetch(new URL(path, origin), { ...options, headers, redirect: 'manual' });
for (const cookie of response.headers.getSetCookie()) {
const pair = cookie.split(';')[0], separator = pair.indexOf('=');
this.jar.set(pair.slice(0, separator), pair.slice(separator + 1));
}
return response;
}
async csrf(path) {
const response = await this.request(path);
if (response.status !== 200) throw new Error('Cannot load local login form: HTTP ' + response.status);
const token = (await response.text()).match(/name="__RequestVerificationToken"[^>]*value="([^"]+)"/)?.[1];
if (!token) throw new Error('Antiforgery token missing.');
this.token = token.replaceAll('&', '&');
}
async login(email, password) {
await this.csrf('/Account/Login');
const response = await this.request('/Account/Login', {
method: 'POST', body: new URLSearchParams({ Email: email, Password: password, __RequestVerificationToken: this.token })
});
if (response.status !== 302) throw new Error('Demo login failed: HTTP ' + response.status);
await this.csrf('/Nearby');
}
async recommend() {
const start = performance.now();
const response = await this.request('/api/recommendations', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.token },
body: JSON.stringify({ longitude:120.155, latitude:30.274, coordinateSystem:'GCJ02', radiusMeters:3000, mode:'Demo' })
});
const data = await response.json();
if (response.status !== 200 || data.items?.length !== 10 || data.mode !== 'Demo')
throw new Error('Recommendation failed: HTTP ' + response.status);
return { ms: performance.now() - start, candidates: data.candidateCount };
}
}
const sessions = credentials.map(() => new Session());
await Promise.all(sessions.map((s,i) => s.login(...credentials[i])));
await Promise.all(sessions.map(s => s.recommend())); // one warm-up per user
const samples = [];
for (let round = 0; round < 5; round++)
samples.push(...await Promise.all(sessions.map(s => s.recommend())));
const times = samples.map(x => x.ms).sort((a,b) => a-b);
const percentile = p => Math.round(times[Math.ceil(times.length * p)-1] * 100) / 100;
const report = {
measuredAt: new Date().toISOString(), environment:'Local Kestrel + SQL Server LocalDB, synthetic data',
users:20, concurrency:20, rounds:5, requests:times.length, failures:0,
minMs:percentile(.01), p50Ms:percentile(.5), p95Ms:percentile(.95), maxMs:percentile(1),
meanMs:Math.round(times.reduce((a,b)=>a+b,0)/times.length*100)/100,
candidateCount:samples[0].candidates, targetP95Ms:1000, passed:percentile(.95)<1000
};
const output = new URL('artifacts/', root);
await mkdir(output, {recursive:true});
await writeFile(new URL('benchmark.json', output), JSON.stringify(report, null, 2) + '\n');
console.log(JSON.stringify(report, null, 2));
if (!report.passed) process.exitCode = 1;
// Local, synthetic demo accounts only. No credentials, locations or diary text are reported.
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { performance } from 'node:perf_hooks';
const origin = new URL(process.argv[2] || 'http://localhost:5188');
if (!['localhost', '127.0.0.1'].includes(origin.hostname) || !['http:', 'https:'].includes(origin.protocol))
throw new Error('This benchmark only supports a local EARSystem development server.');
const root = new URL('../', import.meta.url);
const credentials = (await readFile(new URL('src/EARSystem.Web/App_Data/demo-credentials.txt', root), 'utf8'))
.trim().split(/\r?\n/).map(line => line.split('\t')).filter(x => /^demo\d\d@ear\.local$/.test(x[0]));
if (credentials.length !== 20) throw new Error('Generate the 20 demo accounts first; see README.');
class Session {
jar = new Map();
token = '';
async request(path, options = {}) {
const headers = { ...options.headers, Cookie: [...this.jar].map(([k,v]) => k + '=' + v).join('; ') };
const response = await fetch(new URL(path, origin), { ...options, headers, redirect: 'manual' });
for (const cookie of response.headers.getSetCookie()) {
const pair = cookie.split(';')[0], separator = pair.indexOf('=');
this.jar.set(pair.slice(0, separator), pair.slice(separator + 1));
}
return response;
}
async csrf(path) {
const response = await this.request(path);
if (response.status !== 200) throw new Error('Cannot load local login form: HTTP ' + response.status);
const token = (await response.text()).match(/name="__RequestVerificationToken"[^>]*value="([^"]+)"/)?.[1];
if (!token) throw new Error('Antiforgery token missing.');
this.token = token.replaceAll('&', '&');
}
async login(email, password) {
await this.csrf('/Account/Login');
const response = await this.request('/Account/Login', {
method: 'POST', body: new URLSearchParams({ Email: email, Password: password, __RequestVerificationToken: this.token })
});
if (response.status !== 302) throw new Error('Demo login failed: HTTP ' + response.status);
await this.csrf('/Nearby');
}
async recommend() {
const start = performance.now();
const response = await this.request('/api/recommendations', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.token },
body: JSON.stringify({ longitude:120.155, latitude:30.274, coordinateSystem:'GCJ02', radiusMeters:3000, mode:'Demo' })
});
const data = await response.json();
if (response.status !== 200 || data.items?.length !== 10 || data.mode !== 'Demo')
throw new Error('Recommendation failed: HTTP ' + response.status);
return { ms: performance.now() - start, candidates: data.candidateCount };
}
}
const sessions = credentials.map(() => new Session());
await Promise.all(sessions.map((s,i) => s.login(...credentials[i])));
await Promise.all(sessions.map(s => s.recommend())); // one warm-up per user
const samples = [];
for (let round = 0; round < 5; round++)
samples.push(...await Promise.all(sessions.map(s => s.recommend())));
const times = samples.map(x => x.ms).sort((a,b) => a-b);
const percentile = p => Math.round(times[Math.ceil(times.length * p)-1] * 100) / 100;
const report = {
measuredAt: new Date().toISOString(), environment:'Local Kestrel + SQL Server LocalDB, synthetic data',
users:20, concurrency:20, rounds:5, requests:times.length, failures:0,
minMs:percentile(.01), p50Ms:percentile(.5), p95Ms:percentile(.95), maxMs:percentile(1),
meanMs:Math.round(times.reduce((a,b)=>a+b,0)/times.length*100)/100,
candidateCount:samples[0].candidates, targetP95Ms:1000, passed:percentile(.95)<1000
};
const output = new URL('artifacts/', root);
await mkdir(output, {recursive:true});
await writeFile(new URL('benchmark.json', output), JSON.stringify(report, null, 2) + '\n');
console.log(JSON.stringify(report, null, 2));
if (!report.passed) process.exitCode = 1;