Skip to content

Commit 2a41293

Browse files
authored
Merge pull request #288 from bbopen/test/0.9-suite-quality
test(quality): remove vacuous and duplicate tests, resurrect dead suites, add discriminating coverage
2 parents 349a5ca + f214940 commit 2a41293

16 files changed

Lines changed: 257 additions & 2794 deletions

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ jobs:
9898
run: python -m pip install -r test/python/requirements-suite-core.txt
9999
- name: Run suite core
100100
run: python test/python/library_integration.py --suite core
101+
- name: Run Python codec and frame tests
102+
run: |
103+
python -m pip install pytest
104+
python -m pytest test/python/test_bridge_codec.py test/python/test_frame_codec.py
101105
102106
python-suite-data:
103107
if: github.event_name != 'schedule'

test/adversarial_playground.test.ts

Lines changed: 79 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import { delimiter, join } from 'node:path';
55
import { NodeBridge } from '../src/runtime/node.js';
66
import { resolvePythonExecutable } from '../src/utils/python.js';
77
import { isNodejs } from '../src/utils/runtime.js';
8+
import { PYTHON_AVAILABLE, hasPythonModule } from './helpers/python-probe.js';
89

9-
const shouldRun = isNodejs() && process.env.TYWRAP_ADVERSARIAL === '1';
10+
const scriptPath = join(process.cwd(), 'runtime', 'python_bridge.py');
11+
const fixturesRoot = join(process.cwd(), 'test', 'fixtures', 'python');
12+
const shouldRun =
13+
isNodejs() && PYTHON_AVAILABLE && existsSync(scriptPath) && existsSync(fixturesRoot);
1014
const describeAdversarial = shouldRun ? describe : describe.skip;
1115
const testTimeoutMs = shouldRun ? 15_000 : 5_000;
16+
const SKLEARN_AVAILABLE = shouldRun && hasPythonModule('sklearn');
1217

13-
const scriptPath = join(process.cwd(), 'runtime', 'python_bridge.py');
14-
const fixturesRoot = join(process.cwd(), 'test', 'fixtures', 'python');
1518
const fixturesDir = join(process.cwd(), 'test', 'fixtures');
1619
const moduleName = 'adversarial_module';
1720

@@ -68,7 +71,10 @@ const createBridge = async (
6871
return new NodeBridge({
6972
scriptPath,
7073
pythonPath,
71-
timeoutMs: options.timeoutMs ?? 2000,
74+
// Default to the suite budget: error-surfacing tests assert LOUDNESS, not
75+
// latency, and must absorb worker cold-start/import costs under full-suite
76+
// load (#285). Timeout-behavior tests pass explicit small values.
77+
timeoutMs: options.timeoutMs ?? testTimeoutMs,
7278
env: {
7379
// Why: include local adversarial fixtures without mutating global env.
7480
PYTHONPATH: buildPythonPath(),
@@ -79,7 +85,11 @@ const createBridge = async (
7985

8086
const createFixtureBridge = async (
8187
scriptName: string,
82-
options: { timeoutMs?: number; env?: Record<string, string | undefined> } = {}
88+
options: {
89+
timeoutMs?: number;
90+
maxConcurrentPerProcess?: number;
91+
env?: Record<string, string | undefined>;
92+
} = {}
8393
): Promise<NodeBridge | null> => {
8494
const fixtureScript = join(fixturesDir, scriptName);
8595
if (!existsSync(fixtureScript)) {
@@ -92,7 +102,11 @@ const createFixtureBridge = async (
92102
return new NodeBridge({
93103
scriptPath: fixtureScript,
94104
pythonPath,
95-
timeoutMs: options.timeoutMs ?? 2000,
105+
maxConcurrentPerProcess: options.maxConcurrentPerProcess,
106+
// Default to the suite budget: error-surfacing tests assert LOUDNESS, not
107+
// latency, and must absorb worker cold-start/import costs under full-suite
108+
// load (#285). Timeout-behavior tests pass explicit small values.
109+
timeoutMs: options.timeoutMs ?? testTimeoutMs,
96110
env: options.env ?? {},
97111
});
98112
};
@@ -106,15 +120,20 @@ describeAdversarial('Adversarial playground', () => {
106120
it(
107121
'keeps the bridge usable after a timeout',
108122
async () => {
109-
const bridge = await createBridge({ timeoutMs: 200 });
123+
// Timings must absorb the worker's first-serialize import cost when the
124+
// scientific stack is installed (#285): the 4s budget covers a cold
125+
// start (~2s under load) but not the 6s sleep, so the first call times out and
126+
// the recovery echo still fits — with or without numpy present.
127+
const bridge = await createBridge({ timeoutMs: 4_000 });
110128
if (!bridge) return;
111129

112130
try {
113-
await expect(callAdversarial(bridge, 'sleep_and_return', ['ok', 0.4])).rejects.toThrow(
131+
await expect(callAdversarial(bridge, 'sleep_and_return', ['ok', 6.0])).rejects.toThrow(
114132
/timed out/i
115133
);
116-
// Why: allow the slow call to finish so the next request is not blocked.
117-
await delay(500);
134+
// Why: allow the slow call (and any worker restart) to fully drain so
135+
// the recovery echo is not queued behind it.
136+
await delay(6_000);
118137

119138
const result = await callAdversarial(bridge, 'echo', ['still-alive']);
120139
expect(result).toBe('still-alive');
@@ -243,7 +262,7 @@ describeAdversarial('Adversarial playground', () => {
243262

244263
try {
245264
await expect(callAdversarial(bridge, 'return_scipy_complex_sparse', [])).rejects.toThrow(
246-
/Complex sparse matrices are not supported/
265+
/Complex scipy sparse matrices are not supported/
247266
);
248267
} finally {
249268
await bridge.dispose();
@@ -252,18 +271,16 @@ describeAdversarial('Adversarial playground', () => {
252271
testTimeoutMs
253272
);
254273

255-
it(
274+
it.skipIf(!SKLEARN_AVAILABLE)(
256275
'surfaces explicit sklearn non-serializable params errors',
257276
async () => {
258-
if (!(await pythonModuleAvailable('sklearn'))) return;
259-
260-
const bridge = await createBridge();
277+
const bridge = await createBridge({ timeoutMs: testTimeoutMs });
261278
if (!bridge) return;
262279

263280
try {
264281
await expect(
265282
callAdversarial(bridge, 'return_sklearn_unserializable_estimator', [])
266-
).rejects.toThrow(/scikit-learn estimator params are not JSON-serializable/);
283+
).rejects.toThrow(/scikit-learn estimator param .* is not JSON-serializable/);
267284
} finally {
268285
await bridge.dispose();
269286
}
@@ -379,7 +396,7 @@ describeAdversarial('Adversarial playground', () => {
379396
it(
380397
'surfaces invalid JSON payloads explicitly',
381398
async () => {
382-
const bridge = await createBridge();
399+
const bridge = await createBridge({ timeoutMs: testTimeoutMs });
383400
if (!bridge) return;
384401

385402
try {
@@ -396,7 +413,7 @@ describeAdversarial('Adversarial playground', () => {
396413
it(
397414
'treats stdout noise as a protocol error and recovers',
398415
async () => {
399-
const bridge = await createBridge();
416+
const bridge = await createBridge({ timeoutMs: testTimeoutMs });
400417
if (!bridge) return;
401418

402419
try {
@@ -433,19 +450,23 @@ describeAdversarial('Adversarial playground', () => {
433450
it(
434451
'includes recent stderr in timeout errors',
435452
async () => {
436-
const bridge = await createBridge({ timeoutMs: 200 });
453+
// The 4s budget must outlast the worker's first-serialize import cost
454+
// (#285, ~2s under load with the scientific stack installed) so the
455+
// fixture gets to write stderr BEFORE the timeout fires; the 6s sleep
456+
// guarantees the timeout still fires after that.
457+
const bridge = await createBridge({ timeoutMs: 4_000 });
437458
if (!bridge) return;
438459

439460
try {
440-
await callAdversarial(bridge, 'write_stderr_then_sleep', ['stderr-timeout', 0.5]);
461+
await callAdversarial(bridge, 'write_stderr_then_sleep', ['stderr-timeout', 6.0]);
441462
throw new Error('Expected timeout did not occur');
442463
} catch (error) {
443464
const message = error instanceof Error ? error.message : String(error);
444465
expect(message).toMatch(/Recent stderr/);
445466
expect(message).toMatch(/stderr-timeout/);
446467
} finally {
447468
// Why: adversarial test verifies post-timeout recovery even if it masks the original error.
448-
await delay(600);
469+
await delay(6_000);
449470
const result = await callAdversarial(bridge, 'echo', ['post-timeout']);
450471
expect(result).toBe('post-timeout');
451472
await bridge.dispose();
@@ -568,34 +589,6 @@ describeAdversarial('Adversarial playground', () => {
568589
name: 'return_bad_codec_version',
569590
pattern: /Unsupported dataframe envelope codecVersion: 999/,
570591
},
571-
{
572-
name: 'return_bad_encoding',
573-
pattern: /Invalid dataframe envelope: unsupported encoding/,
574-
},
575-
{
576-
name: 'return_missing_b64',
577-
pattern: /Invalid dataframe envelope: missing b64/,
578-
},
579-
{
580-
name: 'return_missing_data',
581-
pattern: /Invalid ndarray envelope: missing data/,
582-
},
583-
{
584-
name: 'return_invalid_sparse_format',
585-
pattern: /Invalid scipy\.sparse envelope: unsupported format/,
586-
},
587-
{
588-
name: 'return_invalid_sparse_shape',
589-
pattern: /Invalid scipy\.sparse envelope: shape must be a 2-item non-negative integer\[\]/,
590-
},
591-
{
592-
name: 'return_invalid_torch_value',
593-
pattern: /Invalid torch\.tensor envelope: value must be an ndarray envelope/,
594-
},
595-
{
596-
name: 'return_invalid_sklearn_payload',
597-
pattern: /Invalid sklearn\.estimator envelope/,
598-
},
599592
];
600593

601594
for (const { name, pattern } of cases) {
@@ -695,7 +688,14 @@ describeAdversarial('Adversarial playground', () => {
695688
it(
696689
'handles out-of-order responses',
697690
async () => {
698-
const bridge = await createFixtureBridge('out_of_order_bridge.py', { timeoutMs: 2000 });
691+
// The fixture only responds once it has TWO requests in flight, so
692+
// pipelining must be opted into: the pool default is one in-flight
693+
// request per worker (#284), under which the second call would never
694+
// be sent and the first would deadlock into its timeout.
695+
const bridge = await createFixtureBridge('out_of_order_bridge.py', {
696+
timeoutMs: 2000,
697+
maxConcurrentPerProcess: 2,
698+
});
699699
if (!bridge) return;
700700

701701
try {
@@ -722,6 +722,7 @@ describeAdversarial('Multi-worker adversarial tests', () => {
722722
options: {
723723
minProcesses?: number;
724724
maxProcesses?: number;
725+
maxConcurrentPerProcess?: number;
725726
timeoutMs?: number;
726727
env?: Record<string, string | undefined>;
727728
} = {}
@@ -738,7 +739,11 @@ describeAdversarial('Multi-worker adversarial tests', () => {
738739
pythonPath,
739740
minProcesses: options.minProcesses ?? 2,
740741
maxProcesses: options.maxProcesses ?? 4,
741-
timeoutMs: options.timeoutMs ?? 2000,
742+
maxConcurrentPerProcess: options.maxConcurrentPerProcess,
743+
// Default to the suite budget: error-surfacing tests assert LOUDNESS, not
744+
// latency, and must absorb worker cold-start/import costs under full-suite
745+
// load (#285). Timeout-behavior tests pass explicit small values.
746+
timeoutMs: options.timeoutMs ?? testTimeoutMs,
742747
env: {
743748
PYTHONPATH: buildPythonPath(),
744749
...options.env,
@@ -806,21 +811,32 @@ describeAdversarial('Multi-worker adversarial tests', () => {
806811
pythonPath,
807812
minProcesses: 2,
808813
maxProcesses: 2,
809-
maxConcurrentPerProcess: 1, // Key: enforce one request per worker for isolation
810-
timeoutMs: 1000,
814+
// explicit: worker is serial; see #284
815+
maxConcurrentPerProcess: 1,
816+
// 5s (not 1s): the post-timeout recovery call below must cover a worker
817+
// restart, whose fresh Python process pays the first-serialize import
818+
// cost on current main (see #285). Slow call sleeps 8s so it still
819+
// exceeds this timeout by a wide margin.
820+
timeoutMs: 5000,
811821
env: { PYTHONPATH: buildPythonPath() },
812822
});
813823

814824
try {
815825
// Initialize to spawn both workers
816826
await bridge.init();
817827

818-
// Warm up both workers to ensure they're ready
819-
await callAdversarial(bridge, 'echo', ['warmup1']);
820-
await callAdversarial(bridge, 'echo', ['warmup2']);
828+
// Warm up both workers CONCURRENTLY: with maxConcurrentPerProcess: 1 the two
829+
// in-flight calls must land on distinct workers, so each worker pays its
830+
// first-serialize cost here rather than inside the timed fast call below.
831+
// (Sequential warmups both land on worker 1 — it frees up between calls —
832+
// leaving worker 2 cold; see #285 for the first-call import cost.)
833+
await Promise.all([
834+
callAdversarial(bridge, 'echo', ['warmup1']),
835+
callAdversarial(bridge, 'echo', ['warmup2']),
836+
]);
821837

822838
// Start a slow request (will timeout) - occupies worker 1
823-
const slow = callAdversarial(bridge, 'sleep_and_return', ['slow', 2.0]);
839+
const slow = callAdversarial(bridge, 'sleep_and_return', ['slow', 8.0]);
824840

825841
// Give slow request time to start processing
826842
await delay(150);
@@ -981,7 +997,12 @@ describeAdversarial('Multi-worker adversarial tests', () => {
981997
it(
982998
'maintains pool health after protocol errors',
983999
async () => {
984-
const bridge = await createPooledBridge({ minProcesses: 2, maxProcesses: 2 });
1000+
const bridge = await createPooledBridge({
1001+
minProcesses: 2,
1002+
maxProcesses: 2,
1003+
// explicit: worker is serial; see #284
1004+
maxConcurrentPerProcess: 1,
1005+
});
9851006
if (!bridge) return;
9861007

9871008
try {

test/arch-stories.test.ts

Lines changed: 0 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,6 @@ import {
2222
BridgeProtocolError,
2323
BridgeTimeoutError,
2424
} from '../src/runtime/errors.js';
25-
import {
26-
isFiniteNumber,
27-
isPositiveNumber,
28-
isNonNegativeNumber,
29-
assertFiniteNumber,
30-
assertPositive,
31-
assertNonNegative,
32-
containsSpecialFloat,
33-
assertNoSpecialFloats,
34-
ValidationError,
35-
} from '../src/runtime/validators.js';
3625
import { NodeBridge } from '../src/runtime/node.js';
3726
import { PyodideBridge } from '../src/runtime/pyodide.js';
3827
import { HttpBridge } from '../src/runtime/http.js';
@@ -80,57 +69,6 @@ class TestBridge extends DisposableBase {
8069
// ═══════════════════════════════════════════════════════════════════════════
8170

8271
describe('Issue #141: Unified numeric validation layer', () => {
83-
describe('Acceptance: All bridge constructors validate numeric options', () => {
84-
it('validators reject NaN', () => {
85-
expect(isFiniteNumber(NaN)).toBe(false);
86-
expect(isPositiveNumber(NaN)).toBe(false);
87-
expect(() => assertFiniteNumber(NaN, 'test')).toThrow(ValidationError);
88-
expect(() => assertPositive(NaN, 'test')).toThrow(ValidationError);
89-
});
90-
91-
it('validators reject Infinity', () => {
92-
expect(isFiniteNumber(Infinity)).toBe(false);
93-
expect(isFiniteNumber(-Infinity)).toBe(false);
94-
expect(isPositiveNumber(Infinity)).toBe(false);
95-
expect(() => assertFiniteNumber(Infinity, 'test')).toThrow(ValidationError);
96-
});
97-
98-
it('validators reject negative numbers when positive required', () => {
99-
expect(isPositiveNumber(-1)).toBe(false);
100-
expect(isPositiveNumber(0)).toBe(false);
101-
expect(() => assertPositive(-1, 'test')).toThrow(ValidationError);
102-
expect(() => assertPositive(0, 'test')).toThrow(ValidationError);
103-
});
104-
105-
it('validators accept valid positive numbers', () => {
106-
expect(isPositiveNumber(1)).toBe(true);
107-
expect(isPositiveNumber(0.001)).toBe(true);
108-
expect(assertPositive(42, 'test')).toBe(42);
109-
});
110-
});
111-
112-
describe('Acceptance: Deep NaN/Infinity detection in arguments', () => {
113-
it('containsSpecialFloat detects NaN in nested objects', () => {
114-
expect(containsSpecialFloat({ a: { b: NaN } })).toBe(true);
115-
expect(containsSpecialFloat([1, [2, NaN]])).toBe(true);
116-
expect(containsSpecialFloat({ arr: [1, Infinity] })).toBe(true);
117-
});
118-
119-
it('containsSpecialFloat returns false for valid data', () => {
120-
expect(containsSpecialFloat({ a: 1, b: 'str', c: null })).toBe(false);
121-
expect(containsSpecialFloat([1, 2, 3])).toBe(false);
122-
});
123-
124-
it('assertNoSpecialFloats throws for invalid data', () => {
125-
expect(() => assertNoSpecialFloats({ x: NaN }, 'args')).toThrow(ValidationError);
126-
expect(() => assertNoSpecialFloats([Infinity], 'args')).toThrow(ValidationError);
127-
});
128-
129-
it('assertNoSpecialFloats passes for valid data', () => {
130-
expect(() => assertNoSpecialFloats({ x: 1 }, 'args')).not.toThrow();
131-
});
132-
});
133-
13472
describe('Acceptance: BoundedContext validation helpers', () => {
13573
let bridge: TestBridge;
13674

@@ -154,18 +92,6 @@ describe('Issue #141: Unified numeric validation layer', () => {
15492
expect(() => bridge.testValidatePositive(0, 'maxRetries')).toThrow(BridgeProtocolError);
15593
});
15694
});
157-
158-
describe('Related issues coverage', () => {
159-
it('#114/#87: Guards against negative/NaN timeoutMs', () => {
160-
expect(() => assertPositive(NaN, 'timeoutMs')).toThrow();
161-
expect(() => assertPositive(-100, 'timeoutMs')).toThrow();
162-
});
163-
164-
it('#95/#93: Rejects NaN/Infinity in serializable data', () => {
165-
expect(containsSpecialFloat({ result: NaN })).toBe(true);
166-
expect(() => assertNoSpecialFloats({ value: Infinity }, 'response')).toThrow();
167-
});
168-
});
16995
});
17096

17197
// ═══════════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)