pongspeedtest 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -0
- package/bin/pong.js +370 -0
- package/lib/speedtest.js +274 -0
- package/lib/ui.js +131 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# pong
|
|
2
|
+
|
|
3
|
+
Fast internet speed test from your terminal. Ping, download, upload, jitter, bufferbloat, and experience scores.
|
|
4
|
+
|
|
5
|
+
Zero dependencies. Works with Node.js 18+.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g pong-speedtest
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Full speed test
|
|
17
|
+
pong
|
|
18
|
+
|
|
19
|
+
# Choose a server
|
|
20
|
+
pong --server lax
|
|
21
|
+
|
|
22
|
+
# JSON output (for scripts and piping)
|
|
23
|
+
pong --json
|
|
24
|
+
|
|
25
|
+
# Compact one-line output
|
|
26
|
+
pong --simple
|
|
27
|
+
|
|
28
|
+
# Skip phases you don't need
|
|
29
|
+
pong --skip upload,bloat
|
|
30
|
+
|
|
31
|
+
# List available servers
|
|
32
|
+
pong --list-servers
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Output
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
▸ pong speed test — Auto (Cloudflare Edge)
|
|
39
|
+
────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
Ping 26.2 ms (Good)
|
|
42
|
+
Jitter 2.1 ms (Excellent)
|
|
43
|
+
|
|
44
|
+
Download 302 Mbps (Excellent)
|
|
45
|
+
|
|
46
|
+
Upload 36.3 Mbps (Good)
|
|
47
|
+
|
|
48
|
+
Bloat Grade A (+3ms under load)
|
|
49
|
+
|
|
50
|
+
────────────────────────────────────────────────
|
|
51
|
+
Experience Scores
|
|
52
|
+
|
|
53
|
+
4K Video ████████████████████ 100/100
|
|
54
|
+
Video Call ██████████████████░░ 89/100
|
|
55
|
+
Gaming ██████████████████░░ 89/100
|
|
56
|
+
Web ███████████████████░ 96/100
|
|
57
|
+
|
|
58
|
+
Share results: https://pong.com/results?src=cli&...
|
|
59
|
+
|
|
60
|
+
Powered by pong.com
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## JSON Output
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pong --json | jq .download
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"server": { "code": "auto", "name": "Auto (Cloudflare Edge)" },
|
|
72
|
+
"ping": 26.2,
|
|
73
|
+
"jitter": 2.1,
|
|
74
|
+
"download": 302.4,
|
|
75
|
+
"upload": 36.3,
|
|
76
|
+
"bufferbloat": { "grade": "A", "bloatMs": 3.2 },
|
|
77
|
+
"scores": { "video4k": 100, "videoCall": 89, "gaming": 89, "web": 96 },
|
|
78
|
+
"resultsUrl": "https://pong.com/results?src=cli&..."
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Servers
|
|
83
|
+
|
|
84
|
+
| Code | Location |
|
|
85
|
+
|------|----------|
|
|
86
|
+
| auto | Cloudflare Edge (nearest, default) |
|
|
87
|
+
| ewr | Newark, US |
|
|
88
|
+
| lax | Los Angeles, US |
|
|
89
|
+
| yyz | Toronto, CA |
|
|
90
|
+
| lhr | London, GB |
|
|
91
|
+
| fra | Frankfurt, DE |
|
|
92
|
+
| nrt | Tokyo, JP |
|
|
93
|
+
| sin | Singapore, SG |
|
|
94
|
+
| bom | Mumbai, IN |
|
|
95
|
+
| syd | Sydney, AU |
|
|
96
|
+
| gru | Sao Paulo, BR |
|
|
97
|
+
|
|
98
|
+
## What's Measured
|
|
99
|
+
|
|
100
|
+
- **Ping**: Round-trip latency (trimmed mean of 20 samples)
|
|
101
|
+
- **Jitter**: Latency variation (mean absolute deviation)
|
|
102
|
+
- **Download**: Multi-stream download throughput
|
|
103
|
+
- **Upload**: Multi-stream upload throughput
|
|
104
|
+
- **Bufferbloat**: Latency increase under load (graded A through F)
|
|
105
|
+
- **Experience Scores**: 4K Video, Video Call, Gaming, Web Browsing (0 to 100)
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT. By [pong.com](https://pong.com).
|
package/bin/pong.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
4
|
+
// pong — fast internet speed test from your terminal
|
|
5
|
+
// https://pong.com
|
|
6
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
SERVERS,
|
|
10
|
+
runPing,
|
|
11
|
+
runDownload,
|
|
12
|
+
runUpload,
|
|
13
|
+
runBufferbloat,
|
|
14
|
+
scoreVideo4k,
|
|
15
|
+
scoreVideoCall,
|
|
16
|
+
scoreGaming,
|
|
17
|
+
scoreWeb,
|
|
18
|
+
} from "../lib/speedtest.js";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
c,
|
|
22
|
+
startSpinner,
|
|
23
|
+
stopSpinner,
|
|
24
|
+
progressBar,
|
|
25
|
+
ratingColor,
|
|
26
|
+
ratingLabel,
|
|
27
|
+
bloatColor,
|
|
28
|
+
scoreColor,
|
|
29
|
+
box,
|
|
30
|
+
} from "../lib/ui.js";
|
|
31
|
+
|
|
32
|
+
// ── Version ───────────────────────────────────────────────────────
|
|
33
|
+
const VERSION = "1.0.0";
|
|
34
|
+
|
|
35
|
+
// ── Argument parsing ──────────────────────────────────────────────
|
|
36
|
+
const args = process.argv.slice(2);
|
|
37
|
+
const flags = {
|
|
38
|
+
help: args.includes("--help") || args.includes("-h"),
|
|
39
|
+
version: args.includes("--version") || args.includes("-v"),
|
|
40
|
+
json: args.includes("--json"),
|
|
41
|
+
simple: args.includes("--simple"),
|
|
42
|
+
listServers: args.includes("--list-servers") || args.includes("--servers"),
|
|
43
|
+
noColor: args.includes("--no-color"),
|
|
44
|
+
server: null,
|
|
45
|
+
skip: new Set(),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Parse --server <code>
|
|
49
|
+
const serverIdx = args.findIndex((a) => a === "--server" || a === "-s");
|
|
50
|
+
if (serverIdx !== -1 && args[serverIdx + 1]) {
|
|
51
|
+
flags.server = args[serverIdx + 1].toLowerCase();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Parse --skip <phases>
|
|
55
|
+
const skipIdx = args.findIndex((a) => a === "--skip");
|
|
56
|
+
if (skipIdx !== -1 && args[skipIdx + 1]) {
|
|
57
|
+
args[skipIdx + 1].split(",").forEach((s) => flags.skip.add(s.trim().toLowerCase()));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Help ──────────────────────────────────────────────────────────
|
|
61
|
+
if (flags.help) {
|
|
62
|
+
console.log(`
|
|
63
|
+
${c.bold}${c.cyan}pong${c.reset} ${c.dim}v${VERSION}${c.reset} — fast internet speed test
|
|
64
|
+
|
|
65
|
+
${c.bold}USAGE${c.reset}
|
|
66
|
+
pong Run full speed test
|
|
67
|
+
pong --server lax Use specific server
|
|
68
|
+
pong --json Output results as JSON
|
|
69
|
+
pong --simple Compact single-line output
|
|
70
|
+
pong --list-servers Show available servers
|
|
71
|
+
pong --skip upload Skip phases (ping,download,upload,bloat)
|
|
72
|
+
|
|
73
|
+
${c.bold}OPTIONS${c.reset}
|
|
74
|
+
-s, --server <code> Server code (default: auto)
|
|
75
|
+
--json JSON output (for scripts)
|
|
76
|
+
--simple One-line summary
|
|
77
|
+
--skip <phases> Comma-separated phases to skip
|
|
78
|
+
--list-servers List available test servers
|
|
79
|
+
--no-color Disable colors
|
|
80
|
+
-v, --version Show version
|
|
81
|
+
-h, --help Show this help
|
|
82
|
+
|
|
83
|
+
${c.bold}EXAMPLES${c.reset}
|
|
84
|
+
${c.dim}$${c.reset} pong
|
|
85
|
+
${c.dim}$${c.reset} pong --server nrt --json
|
|
86
|
+
${c.dim}$${c.reset} pong --skip bloat,upload --simple
|
|
87
|
+
|
|
88
|
+
${c.dim}https://pong.com${c.reset}
|
|
89
|
+
`);
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (flags.version) {
|
|
94
|
+
console.log(VERSION);
|
|
95
|
+
process.exit(0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── List servers ──────────────────────────────────────────────────
|
|
99
|
+
if (flags.listServers) {
|
|
100
|
+
console.log(`\n${c.bold} Available servers${c.reset}\n`);
|
|
101
|
+
for (const [code, srv] of Object.entries(SERVERS)) {
|
|
102
|
+
const tag = code === "auto" ? `${c.green}(default)${c.reset}` : "";
|
|
103
|
+
console.log(` ${c.cyan}${code.padEnd(6)}${c.reset} ${srv.name} ${tag}`);
|
|
104
|
+
}
|
|
105
|
+
console.log();
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Resolve server ────────────────────────────────────────────────
|
|
110
|
+
function resolveServer(code) {
|
|
111
|
+
if (!code || code === "auto") return SERVERS.auto;
|
|
112
|
+
const srv = SERVERS[code];
|
|
113
|
+
if (!srv) {
|
|
114
|
+
console.error(
|
|
115
|
+
`${c.red}Error:${c.reset} Unknown server "${code}". Use ${c.cyan}--list-servers${c.reset} to see options.`
|
|
116
|
+
);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
return srv;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Main ──────────────────────────────────────────────────────────
|
|
123
|
+
async function main() {
|
|
124
|
+
const server = resolveServer(flags.server);
|
|
125
|
+
const base = server.url;
|
|
126
|
+
const ac = new AbortController();
|
|
127
|
+
const { signal } = ac;
|
|
128
|
+
|
|
129
|
+
// Handle Ctrl+C gracefully
|
|
130
|
+
process.on("SIGINT", () => {
|
|
131
|
+
ac.abort();
|
|
132
|
+
stopSpinner();
|
|
133
|
+
console.log(`\n\n ${c.dim}Test cancelled.${c.reset}\n`);
|
|
134
|
+
process.exit(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const results = {
|
|
138
|
+
server: { code: server.code, name: server.name },
|
|
139
|
+
ping: null,
|
|
140
|
+
jitter: null,
|
|
141
|
+
download: null,
|
|
142
|
+
upload: null,
|
|
143
|
+
bufferbloat: null,
|
|
144
|
+
scores: null,
|
|
145
|
+
timestamp: new Date().toISOString(),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (!flags.json && !flags.simple) {
|
|
149
|
+
console.log();
|
|
150
|
+
console.log(
|
|
151
|
+
` ${c.bold}${c.cyan}▸ pong${c.reset} ${c.dim}speed test${c.reset} ${c.dim}—${c.reset} ${c.dim}${server.name}${c.reset}`
|
|
152
|
+
);
|
|
153
|
+
console.log(` ${c.dim}${"─".repeat(48)}${c.reset}`);
|
|
154
|
+
console.log();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Ping ──────────────────────────────────────────────────────
|
|
158
|
+
if (!flags.skip.has("ping")) {
|
|
159
|
+
if (!flags.json && !flags.simple) startSpinner("Measuring latency...");
|
|
160
|
+
let pingStarted = false;
|
|
161
|
+
|
|
162
|
+
const pingResult = await runPing(base, signal, (ms, i, total) => {
|
|
163
|
+
if (!flags.json && !flags.simple) {
|
|
164
|
+
if (!pingStarted) { stopSpinner(); pingStarted = true; }
|
|
165
|
+
progressBar("Ping", ms, "ms", 100);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
stopSpinner();
|
|
170
|
+
|
|
171
|
+
if (pingResult) {
|
|
172
|
+
results.ping = Math.round(pingResult.ping * 100) / 100;
|
|
173
|
+
results.jitter = Math.round(pingResult.jitter * 100) / 100;
|
|
174
|
+
|
|
175
|
+
if (!flags.json && !flags.simple) {
|
|
176
|
+
const pc = ratingColor("ping", results.ping);
|
|
177
|
+
const jc = ratingColor("jitter", results.jitter);
|
|
178
|
+
console.log(
|
|
179
|
+
` ${c.cyan}Ping ${c.reset} ${pc}${c.bold}${results.ping.toFixed(1)}${c.reset} ${c.dim}ms${c.reset} ${c.dim}(${ratingLabel("ping", results.ping)})${c.reset}`
|
|
180
|
+
);
|
|
181
|
+
console.log(
|
|
182
|
+
` ${c.cyan}Jitter ${c.reset} ${jc}${c.bold}${results.jitter.toFixed(1)}${c.reset} ${c.dim}ms${c.reset} ${c.dim}(${ratingLabel("jitter", results.jitter)})${c.reset}`
|
|
183
|
+
);
|
|
184
|
+
console.log();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Download ──────────────────────────────────────────────────
|
|
190
|
+
if (!flags.skip.has("download")) {
|
|
191
|
+
if (!flags.json && !flags.simple) startSpinner("Testing download speed...");
|
|
192
|
+
let dlStarted = false;
|
|
193
|
+
|
|
194
|
+
const dlResult = await runDownload(base, signal, (mbps) => {
|
|
195
|
+
if (!flags.json && !flags.simple) {
|
|
196
|
+
if (!dlStarted) { stopSpinner(); dlStarted = true; }
|
|
197
|
+
progressBar("Download", mbps, "Mbps", 500);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
stopSpinner();
|
|
202
|
+
|
|
203
|
+
if (dlResult) {
|
|
204
|
+
results.download = Math.round(dlResult * 100) / 100;
|
|
205
|
+
|
|
206
|
+
if (!flags.json && !flags.simple) {
|
|
207
|
+
const dc = ratingColor("speed", results.download);
|
|
208
|
+
console.log(
|
|
209
|
+
` ${c.cyan}Download ${c.reset} ${dc}${c.bold}${formatSpeed(results.download)}${c.reset} ${c.dim}Mbps${c.reset} ${c.dim}(${ratingLabel("speed", results.download)})${c.reset}`
|
|
210
|
+
);
|
|
211
|
+
console.log();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Upload ────────────────────────────────────────────────────
|
|
217
|
+
if (!flags.skip.has("upload")) {
|
|
218
|
+
if (!flags.json && !flags.simple) startSpinner("Testing upload speed...");
|
|
219
|
+
let ulStarted = false;
|
|
220
|
+
|
|
221
|
+
const ulResult = await runUpload(base, signal, (mbps) => {
|
|
222
|
+
if (!flags.json && !flags.simple) {
|
|
223
|
+
if (!ulStarted) { stopSpinner(); ulStarted = true; }
|
|
224
|
+
progressBar("Upload", mbps, "Mbps", 200);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
stopSpinner();
|
|
229
|
+
|
|
230
|
+
if (ulResult) {
|
|
231
|
+
results.upload = Math.round(ulResult * 100) / 100;
|
|
232
|
+
|
|
233
|
+
if (!flags.json && !flags.simple) {
|
|
234
|
+
const uc = ratingColor("speed", results.upload);
|
|
235
|
+
console.log(
|
|
236
|
+
` ${c.cyan}Upload ${c.reset} ${uc}${c.bold}${formatSpeed(results.upload)}${c.reset} ${c.dim}Mbps${c.reset} ${c.dim}(${ratingLabel("speed", results.upload)})${c.reset}`
|
|
237
|
+
);
|
|
238
|
+
console.log();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Bufferbloat ───────────────────────────────────────────────
|
|
244
|
+
if (!flags.skip.has("bloat")) {
|
|
245
|
+
if (!flags.json && !flags.simple) startSpinner("Checking bufferbloat...");
|
|
246
|
+
|
|
247
|
+
const bloatResult = await runBufferbloat(base, signal, (phase, value) => {
|
|
248
|
+
if (!flags.json && !flags.simple) {
|
|
249
|
+
if (phase === "idle") {
|
|
250
|
+
stopSpinner();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
stopSpinner();
|
|
256
|
+
|
|
257
|
+
if (bloatResult) {
|
|
258
|
+
results.bufferbloat = {
|
|
259
|
+
idleLatency: Math.round(bloatResult.idleLatency * 100) / 100,
|
|
260
|
+
loadedLatency: Math.round(bloatResult.loadedLatency * 100) / 100,
|
|
261
|
+
bloatMs: Math.round(bloatResult.bloatMs * 100) / 100,
|
|
262
|
+
grade: bloatResult.grade,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
if (!flags.json && !flags.simple) {
|
|
266
|
+
const bc = bloatColor(bloatResult.grade);
|
|
267
|
+
console.log(
|
|
268
|
+
` ${c.cyan}Bloat ${c.reset} ${bc}${c.bold}Grade ${bloatResult.grade}${c.reset} ${c.dim}(+${Math.round(bloatResult.bloatMs)}ms under load)${c.reset}`
|
|
269
|
+
);
|
|
270
|
+
console.log();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Experience Scores ─────────────────────────────────────────
|
|
276
|
+
if (results.ping != null && results.download != null) {
|
|
277
|
+
const dl = results.download;
|
|
278
|
+
const ul = results.upload ?? 0;
|
|
279
|
+
const ping = results.ping;
|
|
280
|
+
const jitter = results.jitter ?? 0;
|
|
281
|
+
|
|
282
|
+
results.scores = {
|
|
283
|
+
video4k: scoreVideo4k(dl, ping, jitter),
|
|
284
|
+
videoCall: scoreVideoCall(dl, ul, ping, jitter),
|
|
285
|
+
gaming: scoreGaming(ping, jitter, dl),
|
|
286
|
+
web: scoreWeb(dl, ping),
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
if (!flags.json && !flags.simple) {
|
|
290
|
+
console.log(` ${c.dim}${"─".repeat(48)}${c.reset}`);
|
|
291
|
+
console.log(` ${c.bold}Experience Scores${c.reset}`);
|
|
292
|
+
console.log();
|
|
293
|
+
|
|
294
|
+
const scoreEntries = [
|
|
295
|
+
["4K Video", results.scores.video4k],
|
|
296
|
+
["Video Call", results.scores.videoCall],
|
|
297
|
+
["Gaming", results.scores.gaming],
|
|
298
|
+
["Web", results.scores.web],
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
for (const [label, score] of scoreEntries) {
|
|
302
|
+
const sc = scoreColor(score);
|
|
303
|
+
const bar = scoreBar(score);
|
|
304
|
+
console.log(
|
|
305
|
+
` ${label.padEnd(12)} ${sc}${bar}${c.reset} ${sc}${c.bold}${score}${c.reset}${c.dim}/100${c.reset}`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
console.log();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Results URL ───────────────────────────────────────────────
|
|
313
|
+
const resultsUrl = buildResultsUrl(results);
|
|
314
|
+
results.resultsUrl = resultsUrl;
|
|
315
|
+
|
|
316
|
+
// ── Output ────────────────────────────────────────────────────
|
|
317
|
+
if (flags.json) {
|
|
318
|
+
console.log(JSON.stringify(results, null, 2));
|
|
319
|
+
} else if (flags.simple) {
|
|
320
|
+
const parts = [];
|
|
321
|
+
if (results.ping != null) parts.push(`Ping: ${results.ping.toFixed(1)}ms`);
|
|
322
|
+
if (results.download != null) parts.push(`DL: ${formatSpeed(results.download)} Mbps`);
|
|
323
|
+
if (results.upload != null) parts.push(`UL: ${formatSpeed(results.upload)} Mbps`);
|
|
324
|
+
if (results.jitter != null) parts.push(`Jitter: ${results.jitter.toFixed(1)}ms`);
|
|
325
|
+
if (results.bufferbloat) parts.push(`Bloat: ${results.bufferbloat.grade}`);
|
|
326
|
+
console.log(parts.join(" | "));
|
|
327
|
+
} else {
|
|
328
|
+
// Show results URL
|
|
329
|
+
console.log(` ${c.dim}Share results:${c.reset} ${c.cyan}${resultsUrl}${c.reset}`);
|
|
330
|
+
console.log();
|
|
331
|
+
console.log(` ${c.dim}Powered by pong.com${c.reset}`);
|
|
332
|
+
console.log();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
337
|
+
function formatSpeed(mbps) {
|
|
338
|
+
if (mbps >= 100) return mbps.toFixed(0);
|
|
339
|
+
if (mbps >= 10) return mbps.toFixed(1);
|
|
340
|
+
return mbps.toFixed(2);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function scoreBar(score) {
|
|
344
|
+
const width = 20;
|
|
345
|
+
const filled = Math.round((score / 100) * width);
|
|
346
|
+
const empty = width - filled;
|
|
347
|
+
return "█".repeat(filled) + "░".repeat(empty);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function buildResultsUrl(results) {
|
|
351
|
+
const params = new URLSearchParams();
|
|
352
|
+
params.set("src", "cli");
|
|
353
|
+
if (results.ping != null) params.set("p", results.ping.toFixed(1));
|
|
354
|
+
if (results.jitter != null) params.set("j", results.jitter.toFixed(1));
|
|
355
|
+
if (results.download != null) params.set("dl", results.download.toFixed(1));
|
|
356
|
+
if (results.upload != null) params.set("ul", results.upload.toFixed(1));
|
|
357
|
+
if (results.bufferbloat) params.set("bb", results.bufferbloat.grade);
|
|
358
|
+
if (results.server.code !== "auto") params.set("srv", results.server.code);
|
|
359
|
+
return `https://pong.com/results?${params.toString()}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── Run ───────────────────────────────────────────────────────────
|
|
363
|
+
main().catch((err) => {
|
|
364
|
+
stopSpinner();
|
|
365
|
+
if (err.name === "AbortError") {
|
|
366
|
+
process.exit(0);
|
|
367
|
+
}
|
|
368
|
+
console.error(`\n${c.red}Error:${c.reset} ${err.message}\n`);
|
|
369
|
+
process.exit(1);
|
|
370
|
+
});
|
package/lib/speedtest.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2
|
+
// Speed test core (zero dependencies, Node 18+ built-in fetch)
|
|
3
|
+
// Mirrors the logic from the Chrome extension popup.js
|
|
4
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
5
|
+
|
|
6
|
+
import { progressBar, stopSpinner } from "./ui.js";
|
|
7
|
+
|
|
8
|
+
// ── Servers ──────────────────────────────────────────────────────
|
|
9
|
+
export const SERVERS = {
|
|
10
|
+
auto: { code: "auto", name: "Auto (Cloudflare Edge)", url: "https://speed.pong.com" },
|
|
11
|
+
ewr: { code: "ewr", name: "Newark, US", url: "https://speed-ewr.pong.com" },
|
|
12
|
+
lax: { code: "lax", name: "Los Angeles, US", url: "https://speed-lax.pong.com" },
|
|
13
|
+
yyz: { code: "yyz", name: "Toronto, CA", url: "https://speed-yyz.pong.com" },
|
|
14
|
+
lhr: { code: "lhr", name: "London, GB", url: "https://speed-lhr.pong.com" },
|
|
15
|
+
fra: { code: "fra", name: "Frankfurt, DE", url: "https://speed-fra.pong.com" },
|
|
16
|
+
nrt: { code: "nrt", name: "Tokyo, JP", url: "https://speed-nrt.pong.com" },
|
|
17
|
+
sin: { code: "sin", name: "Singapore, SG", url: "https://speed-sin.pong.com" },
|
|
18
|
+
bom: { code: "bom", name: "Mumbai, IN", url: "https://speed-bom.pong.com" },
|
|
19
|
+
syd: { code: "syd", name: "Sydney, AU", url: "https://speed-syd.pong.com" },
|
|
20
|
+
gru: { code: "gru", name: "Sao Paulo, BR", url: "https://speed-gru.pong.com" },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// ── Warm up connections ──────────────────────────────────────────
|
|
24
|
+
async function warmUp(base, signal, count = 3) {
|
|
25
|
+
const fetches = Array.from({ length: count }, () =>
|
|
26
|
+
fetch(`${base}/ping?t=${Date.now()}&warm=1`, { signal }).catch(() => null)
|
|
27
|
+
);
|
|
28
|
+
await Promise.all(fetches);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Ping ─────────────────────────────────────────────────────────
|
|
32
|
+
export async function runPing(base, signal, onProgress) {
|
|
33
|
+
await warmUp(base, signal, 1);
|
|
34
|
+
const pings = [];
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < 20; i++) {
|
|
37
|
+
if (signal.aborted) return null;
|
|
38
|
+
const start = performance.now();
|
|
39
|
+
try {
|
|
40
|
+
await fetch(`${base}/ping?t=${Date.now()}`, { signal });
|
|
41
|
+
const ms = performance.now() - start;
|
|
42
|
+
pings.push(ms);
|
|
43
|
+
if (onProgress) onProgress(ms, i + 1, 20);
|
|
44
|
+
} catch {
|
|
45
|
+
if (signal.aborted) return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (pings.length === 0) return null;
|
|
50
|
+
|
|
51
|
+
const sorted = [...pings].sort((a, b) => a - b);
|
|
52
|
+
const trim = Math.max(1, Math.floor(sorted.length * 0.1));
|
|
53
|
+
const trimmed = sorted.slice(trim, sorted.length - trim);
|
|
54
|
+
const avg = trimmed.reduce((a, b) => a + b, 0) / trimmed.length;
|
|
55
|
+
const jitter = trimmed.reduce((s, p) => s + Math.abs(p - avg), 0) / trimmed.length;
|
|
56
|
+
|
|
57
|
+
return { ping: avg, jitter, min: sorted[0], max: sorted[sorted.length - 1] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Download ─────────────────────────────────────────────────────
|
|
61
|
+
export async function runDownload(base, signal, onProgress) {
|
|
62
|
+
await warmUp(base, signal, 8);
|
|
63
|
+
|
|
64
|
+
const phases = [
|
|
65
|
+
{ chunkSize: 4_000_000, streams: 6 },
|
|
66
|
+
{ chunkSize: 16_000_000, streams: 8 },
|
|
67
|
+
{ chunkSize: 25_000_000, streams: 8 },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
let totalBytes = 0;
|
|
71
|
+
const globalStart = performance.now();
|
|
72
|
+
let lastReport = globalStart;
|
|
73
|
+
|
|
74
|
+
for (const phase of phases) {
|
|
75
|
+
if (signal.aborted) return null;
|
|
76
|
+
|
|
77
|
+
const downloads = Array.from({ length: phase.streams }, async () => {
|
|
78
|
+
if (signal.aborted) return;
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(
|
|
81
|
+
`${base}/download?size=${phase.chunkSize}&t=${Date.now()}`,
|
|
82
|
+
{ signal }
|
|
83
|
+
);
|
|
84
|
+
const reader = res.body?.getReader();
|
|
85
|
+
if (!reader) return;
|
|
86
|
+
|
|
87
|
+
while (true) {
|
|
88
|
+
const { done, value } = await reader.read();
|
|
89
|
+
if (done || signal.aborted) break;
|
|
90
|
+
totalBytes += value.byteLength;
|
|
91
|
+
|
|
92
|
+
const now = performance.now();
|
|
93
|
+
if (now - lastReport > 150) {
|
|
94
|
+
const elapsed = (now - globalStart) / 1000;
|
|
95
|
+
const mbps = (totalBytes * 8) / (elapsed * 1_000_000);
|
|
96
|
+
if (onProgress) onProgress(mbps);
|
|
97
|
+
lastReport = now;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// stream error
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await Promise.all(downloads);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const totalTime = (performance.now() - globalStart) / 1000;
|
|
109
|
+
return totalBytes > 0 ? (totalBytes * 8) / (totalTime * 1_000_000) : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Upload ───────────────────────────────────────────────────────
|
|
113
|
+
export async function runUpload(base, signal, onProgress) {
|
|
114
|
+
const UPLOAD_DURATION = 10_000;
|
|
115
|
+
const CHUNK_SIZE = 4_000_000;
|
|
116
|
+
const UPLOAD_STREAMS = 6;
|
|
117
|
+
const uploadData = new Uint8Array(CHUNK_SIZE);
|
|
118
|
+
|
|
119
|
+
let totalBytes = 0;
|
|
120
|
+
const globalStart = performance.now();
|
|
121
|
+
let lastReport = globalStart;
|
|
122
|
+
let running = true;
|
|
123
|
+
|
|
124
|
+
const timer = setTimeout(() => { running = false; }, UPLOAD_DURATION);
|
|
125
|
+
|
|
126
|
+
const workers = Array.from({ length: UPLOAD_STREAMS }, async () => {
|
|
127
|
+
while (running && !signal.aborted) {
|
|
128
|
+
try {
|
|
129
|
+
await fetch(`${base}/upload?t=${Date.now()}`, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
body: uploadData,
|
|
132
|
+
signal,
|
|
133
|
+
});
|
|
134
|
+
totalBytes += CHUNK_SIZE;
|
|
135
|
+
|
|
136
|
+
const now = performance.now();
|
|
137
|
+
if (now - lastReport > 150) {
|
|
138
|
+
const elapsed = (now - globalStart) / 1000;
|
|
139
|
+
const mbps = (totalBytes * 8) / (elapsed * 1_000_000);
|
|
140
|
+
if (onProgress) onProgress(mbps);
|
|
141
|
+
lastReport = now;
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
if (signal.aborted) break;
|
|
145
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await Promise.all(workers);
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
|
|
153
|
+
const totalTime = (performance.now() - globalStart) / 1000;
|
|
154
|
+
return totalBytes > 0 ? (totalBytes * 8) / (totalTime * 1_000_000) : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Bufferbloat ──────────────────────────────────────────────────
|
|
158
|
+
export async function runBufferbloat(base, signal, onProgress) {
|
|
159
|
+
// Measure idle latency
|
|
160
|
+
const idlePings = [];
|
|
161
|
+
for (let i = 0; i < 5; i++) {
|
|
162
|
+
if (signal.aborted) return null;
|
|
163
|
+
const start = performance.now();
|
|
164
|
+
try {
|
|
165
|
+
await fetch(`${base}/ping?t=${Date.now()}`, { signal });
|
|
166
|
+
idlePings.push(performance.now() - start);
|
|
167
|
+
} catch {
|
|
168
|
+
if (signal.aborted) return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (idlePings.length === 0) return null;
|
|
172
|
+
const idleLatency = idlePings.sort((a, b) => a - b)[Math.floor(idlePings.length / 2)];
|
|
173
|
+
|
|
174
|
+
if (onProgress) onProgress("idle", idleLatency);
|
|
175
|
+
|
|
176
|
+
// Start background load
|
|
177
|
+
const loadCtrl = new AbortController();
|
|
178
|
+
signal.addEventListener("abort", () => loadCtrl.abort());
|
|
179
|
+
|
|
180
|
+
for (let i = 0; i < 4; i++) {
|
|
181
|
+
(async () => {
|
|
182
|
+
try {
|
|
183
|
+
const res = await fetch(`${base}/download?size=32000000&t=${Date.now()}-${i}`, {
|
|
184
|
+
signal: loadCtrl.signal,
|
|
185
|
+
});
|
|
186
|
+
const reader = res.body?.getReader();
|
|
187
|
+
if (!reader) return;
|
|
188
|
+
while (true) {
|
|
189
|
+
const { done } = await reader.read();
|
|
190
|
+
if (done) break;
|
|
191
|
+
}
|
|
192
|
+
} catch { /* expected abort */ }
|
|
193
|
+
})();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
197
|
+
|
|
198
|
+
// Measure loaded latency
|
|
199
|
+
const loadedPings = [];
|
|
200
|
+
for (let i = 0; i < 10; i++) {
|
|
201
|
+
if (signal.aborted) { loadCtrl.abort(); return null; }
|
|
202
|
+
const start = performance.now();
|
|
203
|
+
try {
|
|
204
|
+
await fetch(`${base}/ping?t=${Date.now()}-load`, { signal });
|
|
205
|
+
const ms = performance.now() - start;
|
|
206
|
+
loadedPings.push(ms);
|
|
207
|
+
if (onProgress) onProgress("loaded", ms, i + 1, 10);
|
|
208
|
+
} catch {
|
|
209
|
+
if (signal.aborted) { loadCtrl.abort(); return null; }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
loadCtrl.abort();
|
|
214
|
+
|
|
215
|
+
if (loadedPings.length === 0) return null;
|
|
216
|
+
const loadedLatency = loadedPings.sort((a, b) => a - b)[Math.floor(loadedPings.length / 2)];
|
|
217
|
+
const bloat = Math.max(0, loadedLatency - idleLatency);
|
|
218
|
+
|
|
219
|
+
let grade;
|
|
220
|
+
if (bloat <= 5) grade = "A";
|
|
221
|
+
else if (bloat <= 30) grade = "B";
|
|
222
|
+
else if (bloat <= 60) grade = "C";
|
|
223
|
+
else if (bloat <= 200) grade = "D";
|
|
224
|
+
else grade = "F";
|
|
225
|
+
|
|
226
|
+
return { idleLatency, loadedLatency, bloatMs: bloat, grade };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Experience Scores (matches pong.com scoring) ─────────────────
|
|
230
|
+
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
|
|
231
|
+
|
|
232
|
+
export function scoreVideo4k(dl, ping, jitter) {
|
|
233
|
+
let s = 0;
|
|
234
|
+
if (dl >= 50) s += 60; else if (dl >= 25) s += 40 + ((dl - 25) / 25) * 20;
|
|
235
|
+
else if (dl >= 10) s += 15 + ((dl - 10) / 15) * 25; else s += (dl / 10) * 15;
|
|
236
|
+
if (ping <= 30) s += 25; else if (ping <= 60) s += 15 + ((60 - ping) / 30) * 10;
|
|
237
|
+
else s += Math.max(0, 15 - (ping - 60) / 10);
|
|
238
|
+
if (jitter <= 5) s += 15; else if (jitter <= 15) s += 8 + ((15 - jitter) / 10) * 7;
|
|
239
|
+
else s += Math.max(0, 8 - (jitter - 15) / 5);
|
|
240
|
+
return clamp(Math.round(s), 0, 100);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function scoreVideoCall(dl, ul, ping, jitter) {
|
|
244
|
+
const min = Math.min(dl, ul);
|
|
245
|
+
let s = 0;
|
|
246
|
+
if (min >= 10) s += 35; else if (min >= 5) s += 20 + ((min - 5) / 5) * 15;
|
|
247
|
+
else if (min >= 2) s += 8 + ((min - 2) / 3) * 12; else s += (min / 2) * 8;
|
|
248
|
+
if (ping <= 30) s += 30; else if (ping <= 60) s += 18 + ((60 - ping) / 30) * 12;
|
|
249
|
+
else if (ping <= 150) s += 5 + ((150 - ping) / 90) * 13; else s += Math.max(0, 5 - (ping - 150) / 50);
|
|
250
|
+
if (jitter <= 5) s += 20; else if (jitter <= 15) s += 10 + ((15 - jitter) / 10) * 10;
|
|
251
|
+
else if (jitter <= 30) s += 3 + ((30 - jitter) / 15) * 7; else s += Math.max(0, 3 - (jitter - 30) / 10);
|
|
252
|
+
if (ul >= dl * 0.5) s += 15; else s += (ul / (dl * 0.5)) * 15;
|
|
253
|
+
return clamp(Math.round(s), 0, 100);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function scoreGaming(ping, jitter, dl) {
|
|
257
|
+
let s = 0;
|
|
258
|
+
if (ping <= 15) s += 45; else if (ping <= 30) s += 30 + ((30 - ping) / 15) * 15;
|
|
259
|
+
else if (ping <= 60) s += 15 + ((60 - ping) / 30) * 15; else s += Math.max(0, 15 - (ping - 60) / 10);
|
|
260
|
+
if (jitter <= 3) s += 30; else if (jitter <= 10) s += 18 + ((10 - jitter) / 7) * 12;
|
|
261
|
+
else if (jitter <= 20) s += 8 + ((20 - jitter) / 10) * 10; else s += Math.max(0, 8 - (jitter - 20) / 5);
|
|
262
|
+
if (dl >= 25) s += 25; else if (dl >= 10) s += 15 + ((dl - 10) / 15) * 10;
|
|
263
|
+
else s += (dl / 10) * 15;
|
|
264
|
+
return clamp(Math.round(s), 0, 100);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function scoreWeb(dl, ping) {
|
|
268
|
+
let s = 0;
|
|
269
|
+
if (dl >= 50) s += 50; else if (dl >= 10) s += 25 + ((dl - 10) / 40) * 25;
|
|
270
|
+
else s += (dl / 10) * 25;
|
|
271
|
+
if (ping <= 20) s += 50; else if (ping <= 50) s += 30 + ((50 - ping) / 30) * 20;
|
|
272
|
+
else if (ping <= 100) s += 15 + ((100 - ping) / 50) * 15; else s += Math.max(0, 15 - (ping - 100) / 20);
|
|
273
|
+
return clamp(Math.round(s), 0, 100);
|
|
274
|
+
}
|
package/lib/ui.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2
|
+
// Terminal UI helpers (zero dependencies, pure ANSI)
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
const isColorSupported =
|
|
6
|
+
process.env.FORCE_COLOR !== "0" &&
|
|
7
|
+
(process.env.FORCE_COLOR || process.stdout.isTTY);
|
|
8
|
+
|
|
9
|
+
const c = isColorSupported
|
|
10
|
+
? {
|
|
11
|
+
reset: "\x1b[0m",
|
|
12
|
+
bold: "\x1b[1m",
|
|
13
|
+
dim: "\x1b[2m",
|
|
14
|
+
cyan: "\x1b[36m",
|
|
15
|
+
green: "\x1b[32m",
|
|
16
|
+
yellow: "\x1b[33m",
|
|
17
|
+
red: "\x1b[31m",
|
|
18
|
+
white: "\x1b[97m",
|
|
19
|
+
gray: "\x1b[90m",
|
|
20
|
+
bgCyan: "\x1b[46m",
|
|
21
|
+
bgGreen: "\x1b[42m",
|
|
22
|
+
bgYellow: "\x1b[43m",
|
|
23
|
+
bgRed: "\x1b[41m",
|
|
24
|
+
black: "\x1b[30m",
|
|
25
|
+
hide: "\x1b[?25l",
|
|
26
|
+
show: "\x1b[?25h",
|
|
27
|
+
clearLine: "\x1b[2K",
|
|
28
|
+
up: "\x1b[1A",
|
|
29
|
+
}
|
|
30
|
+
: Object.fromEntries(
|
|
31
|
+
[
|
|
32
|
+
"reset","bold","dim","cyan","green","yellow","red","white",
|
|
33
|
+
"gray","bgCyan","bgGreen","bgYellow","bgRed","black",
|
|
34
|
+
"hide","show","clearLine","up",
|
|
35
|
+
].map((k) => [k, ""])
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
export { c };
|
|
39
|
+
|
|
40
|
+
// ── Spinner ──────────────────────────────────────────────────────
|
|
41
|
+
const SPINNER_FRAMES = [" ", ". ", ".. ", "..."];
|
|
42
|
+
let spinnerInterval = null;
|
|
43
|
+
let spinnerFrame = 0;
|
|
44
|
+
|
|
45
|
+
export function startSpinner(text) {
|
|
46
|
+
process.stdout.write(c.hide);
|
|
47
|
+
spinnerFrame = 0;
|
|
48
|
+
spinnerInterval = setInterval(() => {
|
|
49
|
+
spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
50
|
+
process.stdout.write(
|
|
51
|
+
`\r${c.clearLine} ${c.cyan}${SPINNER_FRAMES[spinnerFrame]}${c.reset} ${c.dim}${text}${c.reset}`
|
|
52
|
+
);
|
|
53
|
+
}, 250);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function stopSpinner() {
|
|
57
|
+
if (spinnerInterval) {
|
|
58
|
+
clearInterval(spinnerInterval);
|
|
59
|
+
spinnerInterval = null;
|
|
60
|
+
}
|
|
61
|
+
process.stdout.write(`\r${c.clearLine}${c.show}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Progress bar ─────────────────────────────────────────────────
|
|
65
|
+
export function progressBar(label, value, unit, maxVal) {
|
|
66
|
+
const barWidth = 30;
|
|
67
|
+
const pct = Math.min(value / maxVal, 1);
|
|
68
|
+
const filled = Math.round(barWidth * pct);
|
|
69
|
+
const empty = barWidth - filled;
|
|
70
|
+
|
|
71
|
+
const bar = `${"█".repeat(filled)}${"░".repeat(empty)}`;
|
|
72
|
+
const valStr =
|
|
73
|
+
value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2);
|
|
74
|
+
|
|
75
|
+
process.stdout.write(
|
|
76
|
+
`\r${c.clearLine} ${c.cyan}${label.padEnd(10)}${c.reset} ${c.dim}${bar}${c.reset} ${c.bold}${c.white}${valStr}${c.reset} ${c.dim}${unit}${c.reset}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Rating helpers ───────────────────────────────────────────────
|
|
81
|
+
export function ratingColor(type, value) {
|
|
82
|
+
if (type === "ping" || type === "jitter") {
|
|
83
|
+
if (value <= 10) return c.green;
|
|
84
|
+
if (value <= 30) return c.cyan;
|
|
85
|
+
if (value <= 60) return c.yellow;
|
|
86
|
+
return c.red;
|
|
87
|
+
}
|
|
88
|
+
if (value >= 100) return c.green;
|
|
89
|
+
if (value >= 25) return c.cyan;
|
|
90
|
+
if (value >= 10) return c.yellow;
|
|
91
|
+
return c.red;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function ratingLabel(type, value) {
|
|
95
|
+
if (type === "ping" || type === "jitter") {
|
|
96
|
+
if (value <= 10) return "Excellent";
|
|
97
|
+
if (value <= 30) return "Good";
|
|
98
|
+
if (value <= 60) return "Fair";
|
|
99
|
+
return "Poor";
|
|
100
|
+
}
|
|
101
|
+
if (value >= 100) return "Excellent";
|
|
102
|
+
if (value >= 25) return "Good";
|
|
103
|
+
if (value >= 10) return "Fair";
|
|
104
|
+
return "Poor";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function bloatColor(grade) {
|
|
108
|
+
if (grade <= "B") return c.green;
|
|
109
|
+
if (grade === "C") return c.yellow;
|
|
110
|
+
return c.red;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function scoreColor(score) {
|
|
114
|
+
if (score >= 80) return c.green;
|
|
115
|
+
if (score >= 60) return c.cyan;
|
|
116
|
+
if (score >= 40) return c.yellow;
|
|
117
|
+
return c.red;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Box drawing ──────────────────────────────────────────────────
|
|
121
|
+
export function box(lines, width = 52) {
|
|
122
|
+
const top = ` ${c.dim}╭${"─".repeat(width)}╮${c.reset}`;
|
|
123
|
+
const bot = ` ${c.dim}╰${"─".repeat(width)}╯${c.reset}`;
|
|
124
|
+
const rows = lines.map((l) => {
|
|
125
|
+
// Strip ANSI for length calculation
|
|
126
|
+
const stripped = l.replace(/\x1b\[[0-9;]*m/g, "");
|
|
127
|
+
const pad = Math.max(0, width - 2 - stripped.length);
|
|
128
|
+
return ` ${c.dim}│${c.reset} ${l}${" ".repeat(pad)} ${c.dim}│${c.reset}`;
|
|
129
|
+
});
|
|
130
|
+
return [top, ...rows, bot].join("\n");
|
|
131
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pongspeedtest",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Fast internet speed test from your terminal. Ping, download, upload, jitter & bufferbloat.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"pong": "bin/pong.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18.0.0"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"speedtest",
|
|
14
|
+
"speed-test",
|
|
15
|
+
"internet",
|
|
16
|
+
"bandwidth",
|
|
17
|
+
"ping",
|
|
18
|
+
"download",
|
|
19
|
+
"upload",
|
|
20
|
+
"jitter",
|
|
21
|
+
"bufferbloat",
|
|
22
|
+
"network",
|
|
23
|
+
"cli",
|
|
24
|
+
"pong"
|
|
25
|
+
],
|
|
26
|
+
"author": "pong.com",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"homepage": "https://pong.com",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/pongcom/cli.git"
|
|
32
|
+
}
|
|
33
|
+
}
|