laravel-workers 0.4.0 → 0.4.1

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.
@@ -1,263 +1,263 @@
1
- // kernel/tests/php-json.test.mjs — PHP json_encode() parity.
2
- //
3
- // Proves byte-for-byte parity against PHP 8.3 (default flags,
4
- // serialize_precision=-1) for the session payload subset, in both directions:
5
- // A. phpJsonEncode(value) === PHP json_encode(same value)
6
- // B. phpJsonEncode(JSON.parse(phpString)) === phpString (PHP → JS → bytes)
7
- // C. PHP json_encode(json_decode(jsString, true)) === jsString (JS → PHP → bytes)
8
- import { test } from 'node:test';
9
- import assert from 'node:assert/strict';
10
- import { spawnSync } from 'node:child_process';
11
- import path from 'node:path';
12
- import { fileURLToPath } from 'node:url';
13
- import { phpJsonEncode, phpJsonDecode, PhpJsonError } from '../php-json.mjs';
14
- import { Model } from '../model.mjs';
15
-
16
- const testsDir = path.dirname(fileURLToPath(import.meta.url));
17
- const oraclePath = path.resolve(testsDir, '..', '..', 'tools', 'php-json-oracle.php');
18
- const PHP = process.env.PHP_BIN || 'php';
19
-
20
- function runOracle(mode, payload) {
21
- const res = spawnSync(PHP, [oraclePath], {
22
- input: JSON.stringify({ mode, ...payload }),
23
- encoding: 'utf8',
24
- maxBuffer: 64 * 1024 * 1024,
25
- });
26
- if (res.error) {
27
- throw new Error(
28
- `PHP oracle failed to start (${res.error.message}). Install PHP 8.3 or set PHP_BIN.`
29
- );
30
- }
31
- if (res.status !== 0) {
32
- throw new Error(`PHP oracle exited ${res.status}: ${res.stderr}`);
33
- }
34
- return JSON.parse(res.stdout).results;
35
- }
36
-
37
- function tag(v) {
38
- if (v === null) return { t: 'n' };
39
- if (typeof v === 'boolean') return { t: 'b', v };
40
- if (typeof v === 'number') {
41
- if (Number.isNaN(v)) return { t: 'd', v: 'NAN' };
42
- if (v === Infinity) return { t: 'd', v: 'INF' };
43
- if (v === -Infinity) return { t: 'd', v: '-INF' };
44
- if (Object.is(v, -0)) return { t: 'd', v: '-0' };
45
- if (Number.isInteger(v) && Number.isSafeInteger(v)) return { t: 'i', v: String(v) };
46
- return { t: 'd', v: String(v) };
47
- }
48
- if (typeof v === 'string') return { t: 's', v };
49
- if (Array.isArray(v)) return { t: 'a', v: v.map(tag) };
50
- return { t: 'a', v: Object.fromEntries(Object.entries(v).map(([k, x]) => [k, tag(x)])) };
51
- }
52
-
53
- const PARITY = [
54
- ['null', null],
55
- ['true', true],
56
- ['false', false],
57
- ['int 0', 0],
58
- ['int 1', 1],
59
- ['int -1', -1],
60
- ['int max-safe', 9007199254740991],
61
- ['int min-safe', -9007199254740991],
62
- ['float 0.5', 0.5],
63
- ['float 2.5', 2.5],
64
- ['float 0.1', 0.1],
65
- ['float 0.1+0.2', 0.1 + 0.2],
66
- ['float third', 1 / 3],
67
- ['float 123.456', 123.456],
68
- ['float 1e6 plain', 1e6],
69
- ['float 1e15 plain', 1e15],
70
- ['float 1e16 plain', 1e16],
71
- ['float 1.5e16 plain', 1.5e16],
72
- ['float 9.9e16 plain', 9.9e16],
73
- ['float 1e17 scientific', 1e17],
74
- ['float 1e18 scientific', 1e18],
75
- ['float 1e20 scientific', 1e20],
76
- ['float 1e21 scientific', 1e21],
77
- ['float 1e-4 plain', 1e-4],
78
- ['float 1e-5 scientific', 1e-5],
79
- ['float 1e-6 scientific', 1e-6],
80
- ['float 1e-7 scientific', 1e-7],
81
- ['float min subnormal', 5e-324],
82
- ['float min normal', 2.2250738585072014e-308],
83
- ['float max', 1.7976931348623157e308],
84
- ['float 1.2345678901234567e-17', 1.2345678901234567e-17],
85
- ['empty string', ''],
86
- ['string abc', 'abc'],
87
- ['string quote', 'a"b'],
88
- ['string backslash', 'a\\b'],
89
- ['string slash', 'a/b'],
90
- ['string newline', 'a\nb'],
91
- ['string cr', 'a\rb'],
92
- ['string tab', 'a\tb'],
93
- ['string backspace', 'a\x08b'],
94
- ['string formfeed', 'a\x0cb'],
95
- ['string vertical tab', 'a\x0bb'],
96
- ['string NUL', 'a\x00b'],
97
- ['string ctrl 01', '\x01'],
98
- ['string ctrl 1f', '\x1f'],
99
- ['string DEL raw', 'a\x7fb'],
100
- ['string nbsp', 'a\u00a0b'],
101
- ['string e acute', 'é'],
102
- ['string emoji', '😀'],
103
- ['string u2028 u2029', '\u2028\u2029'],
104
- ['string html', '<>&'],
105
- ['string single quote', "a'b"],
106
- ['string json-ish', '{"data":"x","expires":1}'],
107
- ['string percent', 'a%20b'],
108
- ['empty array', []],
109
- ['list', [1, 2, 3]],
110
- ['list nested', ['a', { b: [null, true, 2.5] }]],
111
- ['empty assoc', {}],
112
- ['assoc', { a: 1 }],
113
- ['assoc order', { b: 1, a: 2 }],
114
- ['assoc int-like keys', { 5: 'five', '007': 'x', '-5': 'neg', 0: 'zero' }],
115
- ['key -0', { '-0': 'negzero' }],
116
- ['key +0', { '+0': 'pluszero' }],
117
- ['key 00', { '00': 'zeros' }],
118
- ['key beyond int64', { '9223372036854775808': 'over' }],
119
- ['key with slash and unicode', { 'a/b': 1, 'é': 2, '😀': 3 }],
120
- [
121
- 'session shape',
122
- {
123
- _token: 'AbC123xyz',
124
- _previous: { url: 'http://localhost/__probe/session', route: null },
125
- _flash: { old: [], new: [] },
126
- counter: 3,
127
- items: ['a', 'b'],
128
- flags: { on: true, off: false },
129
- },
130
- ],
131
- ['previous url shape', { _previous: { url: 'https://example.com/a?b=1' } }],
132
- ];
133
-
134
- test('A: phpJsonEncode matches PHP json_encode() byte-for-byte', () => {
135
- const results = runOracle('encode', { values: PARITY.map(([, v]) => tag(v)) });
136
- for (let i = 0; i < PARITY.length; i++) {
137
- const [label, value] = PARITY[i];
138
- const php = results[i];
139
- assert.ok(!php.err, `${label}: oracle error ${php.err}`);
140
- assert.equal(phpJsonEncode(value), php.out, `encode mismatch: ${label}`);
141
- }
142
- });
143
-
144
- test('B: PHP bytes decode and re-encode back to the same bytes', () => {
145
- const results = runOracle('encode', { values: PARITY.map(([, v]) => tag(v)) });
146
- for (let i = 0; i < PARITY.length; i++) {
147
- const [label] = PARITY[i];
148
- const php = results[i];
149
- assert.ok(!php.err, `${label}: oracle error ${php.err}`);
150
- assert.equal(
151
- phpJsonEncode(phpJsonDecode(php.out)),
152
- php.out,
153
- `round-trip mismatch: ${label}`
154
- );
155
- }
156
- });
157
-
158
- test('C: PHP reads our bytes and re-serializes to the same bytes', () => {
159
- const strings = PARITY.map(([, v]) => phpJsonEncode(v));
160
- const results = runOracle('roundtrip', { strings });
161
- for (let i = 0; i < PARITY.length; i++) {
162
- const [label] = PARITY[i];
163
- const php = results[i];
164
- assert.ok(!php.err, `${label}: oracle error ${php.err}`);
165
- assert.equal(php.reencoded, strings[i], `php round-trip mismatch: ${label}`);
166
- }
167
- });
168
-
169
- test('calibrated formatting matches PHP 8.3 exactly', () => {
170
- // Integral doubles print without a fraction under default json_encode flags.
171
- assert.equal(phpJsonEncode(1.0), '1');
172
- assert.equal(phpJsonEncode(12.0), '12');
173
- assert.equal(phpJsonEncode(-0), '-0');
174
- assert.equal(phpJsonEncode(1.5e16), '15000000000000000');
175
- assert.equal(phpJsonEncode(1e17), '1.0e+17');
176
- assert.equal(phpJsonEncode(1e20), '1.0e+20');
177
- assert.equal(phpJsonEncode(1e-4), '0.0001');
178
- assert.equal(phpJsonEncode(1e-5), '1.0e-5');
179
- assert.equal(phpJsonEncode(5e-324), '5.0e-324');
180
- assert.equal(phpJsonEncode(1.7976931348623157e308), '1.7976931348623157e+308');
181
- assert.equal(phpJsonEncode(1 / 3), '0.3333333333333333');
182
- assert.equal(phpJsonEncode('a/b'), '"a\\/b"');
183
- assert.equal(phpJsonEncode('é'), '"\\u00e9"');
184
- assert.equal(phpJsonEncode('😀'), '"\\ud83d\\ude00"');
185
- assert.equal(phpJsonEncode('\u2028'), '"\\u2028"');
186
- assert.equal(phpJsonEncode('a\x00b'), '"a\\u0000b"');
187
- assert.equal(phpJsonEncode('a\x7fb'), '"a\x7fb"');
188
- assert.equal(phpJsonEncode('a\x08b'), '"a\\bb"');
189
- });
190
-
191
- test('session cookie envelope payload re-encodes byte-for-byte', () => {
192
- const attrs = {
193
- _token: 'AbC123xyz',
194
- _previous: { url: 'http://localhost/__probe/session', route: null },
195
- _flash: { old: [], new: [] },
196
- counter: 3,
197
- items: ['a', 'b'],
198
- flags: { on: true, off: false },
199
- };
200
- const data = phpJsonEncode(attrs);
201
- assert.equal(
202
- data,
203
- '{"_token":"AbC123xyz","_previous":{"url":"http:\\/\\/localhost\\/__probe\\/session","route":null},"_flash":{"old":[],"new":[]},"counter":3,"items":["a","b"],"flags":{"on":true,"off":false}}'
204
- );
205
- const envelope = phpJsonEncode({ data, expires: 1792337099 });
206
- const escapedData = data
207
- .replace(/\\/g, '\\\\')
208
- .replace(/"/g, '\\"')
209
- .replace(/\//g, '\\/');
210
- assert.equal(envelope, '{"data":"' + escapedData + '","expires":1792337099}');
211
- assert.equal(JSON.parse(envelope).data, data);
212
- });
213
-
214
- test('NAN/INF fail loud like PHP json_encode returns false', () => {
215
- const results = runOracle('encode', { values: [{ t: 'd', v: 'NAN' }, { t: 'd', v: 'INF' }] });
216
- assert.match(results[0].err, /NaN cannot be JSON encoded/);
217
- assert.match(results[1].err, /NaN cannot be JSON encoded/);
218
- assert.throws(() => phpJsonEncode(NaN), PhpJsonError);
219
- assert.throws(() => phpJsonEncode(Infinity), PhpJsonError);
220
- assert.throws(() => phpJsonEncode(-Infinity), PhpJsonError);
221
- });
222
-
223
- test('unsupported values fail loud', () => {
224
- assert.throws(() => phpJsonEncode(undefined), PhpJsonError);
225
- assert.throws(() => phpJsonEncode(10n), PhpJsonError);
226
- assert.throws(() => phpJsonEncode(new Date(0)), PhpJsonError);
227
- assert.throws(() => phpJsonEncode({ a: new Set([1]) }), PhpJsonError);
228
- assert.throws(() => phpJsonEncode('\ud800'), PhpJsonError);
229
- assert.throws(() => phpJsonEncode('\udc00'), PhpJsonError);
230
- });
231
-
232
- test('documented normalization: integers beyond 2^53 encode as the double', () => {
233
- assert.equal(phpJsonEncode(9223372036854775807), '"9.223372036854776e+18"'.slice(1, -1));
234
- });
235
-
236
- test('documented divergence: PHP json_decode loses -0 on decode', () => {
237
- // PHP's encoder emits -0 for float -0.0, but its decoder maps the JSON
238
- // token -0 back to int 0 (PHP is not round-trip stable here). JSON.parse
239
- // keeps -0; the encode direction still matches PHP byte-for-byte.
240
- assert.equal(phpJsonEncode(-0), '-0');
241
- const results = runOracle('roundtrip', { strings: ['[-0]'] });
242
- assert.ok(!results[0].err, results[0].err);
243
- assert.equal(results[0].reencoded, '[0]');
244
- assert.ok(Object.is(phpJsonDecode('[-0]')[0], -0));
245
- });
246
-
247
- test('phpJsonDecode reads PHP-produced payloads', () => {
248
- assert.deepEqual(phpJsonDecode('{"a":[1,2],"b":{"c":null}}'), { a: [1, 2], b: { c: null } });
249
- assert.deepEqual(phpJsonDecode('[]'), []);
250
- });
251
-
252
- test("a model is JsonSerializable (Laravel: json_encode($model) = its toArray())", () => {
253
- // Laravel's Model implements JsonSerializable: json_encode($model) answers
254
- // the model's toArray() (its toJSON()). The edge threw "unsupported value
255
- // (Model)", so `response()->json($post)` was a 500.
256
- class M extends Model {}
257
- M.table = 'posts';
258
- const model = new M({ id: 1, title: 'Hello' });
259
- assert.equal(phpJsonEncode(model), '{"id":1,"title":"Hello"}');
260
- // A Date has a toJSON() too - and PHP's json_encode(new DateTime()) answers
261
- // '{}', so it stays unsupported here.
262
- assert.throws(() => phpJsonEncode(new Date(0)), PhpJsonError);
263
- });
1
+ // kernel/tests/php-json.test.mjs — PHP json_encode() parity.
2
+ //
3
+ // Proves byte-for-byte parity against PHP 8.3 (default flags,
4
+ // serialize_precision=-1) for the session payload subset, in both directions:
5
+ // A. phpJsonEncode(value) === PHP json_encode(same value)
6
+ // B. phpJsonEncode(JSON.parse(phpString)) === phpString (PHP → JS → bytes)
7
+ // C. PHP json_encode(json_decode(jsString, true)) === jsString (JS → PHP → bytes)
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { spawnSync } from 'node:child_process';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { phpJsonEncode, phpJsonDecode, PhpJsonError } from '../php-json.mjs';
14
+ import { Model } from '../model.mjs';
15
+
16
+ const testsDir = path.dirname(fileURLToPath(import.meta.url));
17
+ const oraclePath = path.resolve(testsDir, '..', '..', 'tools', 'php-json-oracle.php');
18
+ const PHP = process.env.PHP_BIN || 'php';
19
+
20
+ function runOracle(mode, payload) {
21
+ const res = spawnSync(PHP, [oraclePath], {
22
+ input: JSON.stringify({ mode, ...payload }),
23
+ encoding: 'utf8',
24
+ maxBuffer: 64 * 1024 * 1024,
25
+ });
26
+ if (res.error) {
27
+ throw new Error(
28
+ `PHP oracle failed to start (${res.error.message}). Install PHP 8.3 or set PHP_BIN.`
29
+ );
30
+ }
31
+ if (res.status !== 0) {
32
+ throw new Error(`PHP oracle exited ${res.status}: ${res.stderr}`);
33
+ }
34
+ return JSON.parse(res.stdout).results;
35
+ }
36
+
37
+ function tag(v) {
38
+ if (v === null) return { t: 'n' };
39
+ if (typeof v === 'boolean') return { t: 'b', v };
40
+ if (typeof v === 'number') {
41
+ if (Number.isNaN(v)) return { t: 'd', v: 'NAN' };
42
+ if (v === Infinity) return { t: 'd', v: 'INF' };
43
+ if (v === -Infinity) return { t: 'd', v: '-INF' };
44
+ if (Object.is(v, -0)) return { t: 'd', v: '-0' };
45
+ if (Number.isInteger(v) && Number.isSafeInteger(v)) return { t: 'i', v: String(v) };
46
+ return { t: 'd', v: String(v) };
47
+ }
48
+ if (typeof v === 'string') return { t: 's', v };
49
+ if (Array.isArray(v)) return { t: 'a', v: v.map(tag) };
50
+ return { t: 'a', v: Object.fromEntries(Object.entries(v).map(([k, x]) => [k, tag(x)])) };
51
+ }
52
+
53
+ const PARITY = [
54
+ ['null', null],
55
+ ['true', true],
56
+ ['false', false],
57
+ ['int 0', 0],
58
+ ['int 1', 1],
59
+ ['int -1', -1],
60
+ ['int max-safe', 9007199254740991],
61
+ ['int min-safe', -9007199254740991],
62
+ ['float 0.5', 0.5],
63
+ ['float 2.5', 2.5],
64
+ ['float 0.1', 0.1],
65
+ ['float 0.1+0.2', 0.1 + 0.2],
66
+ ['float third', 1 / 3],
67
+ ['float 123.456', 123.456],
68
+ ['float 1e6 plain', 1e6],
69
+ ['float 1e15 plain', 1e15],
70
+ ['float 1e16 plain', 1e16],
71
+ ['float 1.5e16 plain', 1.5e16],
72
+ ['float 9.9e16 plain', 9.9e16],
73
+ ['float 1e17 scientific', 1e17],
74
+ ['float 1e18 scientific', 1e18],
75
+ ['float 1e20 scientific', 1e20],
76
+ ['float 1e21 scientific', 1e21],
77
+ ['float 1e-4 plain', 1e-4],
78
+ ['float 1e-5 scientific', 1e-5],
79
+ ['float 1e-6 scientific', 1e-6],
80
+ ['float 1e-7 scientific', 1e-7],
81
+ ['float min subnormal', 5e-324],
82
+ ['float min normal', 2.2250738585072014e-308],
83
+ ['float max', 1.7976931348623157e308],
84
+ ['float 1.2345678901234567e-17', 1.2345678901234567e-17],
85
+ ['empty string', ''],
86
+ ['string abc', 'abc'],
87
+ ['string quote', 'a"b'],
88
+ ['string backslash', 'a\\b'],
89
+ ['string slash', 'a/b'],
90
+ ['string newline', 'a\nb'],
91
+ ['string cr', 'a\rb'],
92
+ ['string tab', 'a\tb'],
93
+ ['string backspace', 'a\x08b'],
94
+ ['string formfeed', 'a\x0cb'],
95
+ ['string vertical tab', 'a\x0bb'],
96
+ ['string NUL', 'a\x00b'],
97
+ ['string ctrl 01', '\x01'],
98
+ ['string ctrl 1f', '\x1f'],
99
+ ['string DEL raw', 'a\x7fb'],
100
+ ['string nbsp', 'a\u00a0b'],
101
+ ['string e acute', 'é'],
102
+ ['string emoji', '😀'],
103
+ ['string u2028 u2029', '\u2028\u2029'],
104
+ ['string html', '<>&'],
105
+ ['string single quote', "a'b"],
106
+ ['string json-ish', '{"data":"x","expires":1}'],
107
+ ['string percent', 'a%20b'],
108
+ ['empty array', []],
109
+ ['list', [1, 2, 3]],
110
+ ['list nested', ['a', { b: [null, true, 2.5] }]],
111
+ ['empty assoc', {}],
112
+ ['assoc', { a: 1 }],
113
+ ['assoc order', { b: 1, a: 2 }],
114
+ ['assoc int-like keys', { 5: 'five', '007': 'x', '-5': 'neg', 0: 'zero' }],
115
+ ['key -0', { '-0': 'negzero' }],
116
+ ['key +0', { '+0': 'pluszero' }],
117
+ ['key 00', { '00': 'zeros' }],
118
+ ['key beyond int64', { '9223372036854775808': 'over' }],
119
+ ['key with slash and unicode', { 'a/b': 1, 'é': 2, '😀': 3 }],
120
+ [
121
+ 'session shape',
122
+ {
123
+ _token: 'AbC123xyz',
124
+ _previous: { url: 'http://localhost/__probe/session', route: null },
125
+ _flash: { old: [], new: [] },
126
+ counter: 3,
127
+ items: ['a', 'b'],
128
+ flags: { on: true, off: false },
129
+ },
130
+ ],
131
+ ['previous url shape', { _previous: { url: 'https://example.com/a?b=1' } }],
132
+ ];
133
+
134
+ test('A: phpJsonEncode matches PHP json_encode() byte-for-byte', () => {
135
+ const results = runOracle('encode', { values: PARITY.map(([, v]) => tag(v)) });
136
+ for (let i = 0; i < PARITY.length; i++) {
137
+ const [label, value] = PARITY[i];
138
+ const php = results[i];
139
+ assert.ok(!php.err, `${label}: oracle error ${php.err}`);
140
+ assert.equal(phpJsonEncode(value), php.out, `encode mismatch: ${label}`);
141
+ }
142
+ });
143
+
144
+ test('B: PHP bytes decode and re-encode back to the same bytes', () => {
145
+ const results = runOracle('encode', { values: PARITY.map(([, v]) => tag(v)) });
146
+ for (let i = 0; i < PARITY.length; i++) {
147
+ const [label] = PARITY[i];
148
+ const php = results[i];
149
+ assert.ok(!php.err, `${label}: oracle error ${php.err}`);
150
+ assert.equal(
151
+ phpJsonEncode(phpJsonDecode(php.out)),
152
+ php.out,
153
+ `round-trip mismatch: ${label}`
154
+ );
155
+ }
156
+ });
157
+
158
+ test('C: PHP reads our bytes and re-serializes to the same bytes', () => {
159
+ const strings = PARITY.map(([, v]) => phpJsonEncode(v));
160
+ const results = runOracle('roundtrip', { strings });
161
+ for (let i = 0; i < PARITY.length; i++) {
162
+ const [label] = PARITY[i];
163
+ const php = results[i];
164
+ assert.ok(!php.err, `${label}: oracle error ${php.err}`);
165
+ assert.equal(php.reencoded, strings[i], `php round-trip mismatch: ${label}`);
166
+ }
167
+ });
168
+
169
+ test('calibrated formatting matches PHP 8.3 exactly', () => {
170
+ // Integral doubles print without a fraction under default json_encode flags.
171
+ assert.equal(phpJsonEncode(1.0), '1');
172
+ assert.equal(phpJsonEncode(12.0), '12');
173
+ assert.equal(phpJsonEncode(-0), '-0');
174
+ assert.equal(phpJsonEncode(1.5e16), '15000000000000000');
175
+ assert.equal(phpJsonEncode(1e17), '1.0e+17');
176
+ assert.equal(phpJsonEncode(1e20), '1.0e+20');
177
+ assert.equal(phpJsonEncode(1e-4), '0.0001');
178
+ assert.equal(phpJsonEncode(1e-5), '1.0e-5');
179
+ assert.equal(phpJsonEncode(5e-324), '5.0e-324');
180
+ assert.equal(phpJsonEncode(1.7976931348623157e308), '1.7976931348623157e+308');
181
+ assert.equal(phpJsonEncode(1 / 3), '0.3333333333333333');
182
+ assert.equal(phpJsonEncode('a/b'), '"a\\/b"');
183
+ assert.equal(phpJsonEncode('é'), '"\\u00e9"');
184
+ assert.equal(phpJsonEncode('😀'), '"\\ud83d\\ude00"');
185
+ assert.equal(phpJsonEncode('\u2028'), '"\\u2028"');
186
+ assert.equal(phpJsonEncode('a\x00b'), '"a\\u0000b"');
187
+ assert.equal(phpJsonEncode('a\x7fb'), '"a\x7fb"');
188
+ assert.equal(phpJsonEncode('a\x08b'), '"a\\bb"');
189
+ });
190
+
191
+ test('session cookie envelope payload re-encodes byte-for-byte', () => {
192
+ const attrs = {
193
+ _token: 'AbC123xyz',
194
+ _previous: { url: 'http://localhost/__probe/session', route: null },
195
+ _flash: { old: [], new: [] },
196
+ counter: 3,
197
+ items: ['a', 'b'],
198
+ flags: { on: true, off: false },
199
+ };
200
+ const data = phpJsonEncode(attrs);
201
+ assert.equal(
202
+ data,
203
+ '{"_token":"AbC123xyz","_previous":{"url":"http:\\/\\/localhost\\/__probe\\/session","route":null},"_flash":{"old":[],"new":[]},"counter":3,"items":["a","b"],"flags":{"on":true,"off":false}}'
204
+ );
205
+ const envelope = phpJsonEncode({ data, expires: 1792337099 });
206
+ const escapedData = data
207
+ .replace(/\\/g, '\\\\')
208
+ .replace(/"/g, '\\"')
209
+ .replace(/\//g, '\\/');
210
+ assert.equal(envelope, '{"data":"' + escapedData + '","expires":1792337099}');
211
+ assert.equal(JSON.parse(envelope).data, data);
212
+ });
213
+
214
+ test('NAN/INF fail loud like PHP json_encode returns false', () => {
215
+ const results = runOracle('encode', { values: [{ t: 'd', v: 'NAN' }, { t: 'd', v: 'INF' }] });
216
+ assert.match(results[0].err, /NaN cannot be JSON encoded/);
217
+ assert.match(results[1].err, /NaN cannot be JSON encoded/);
218
+ assert.throws(() => phpJsonEncode(NaN), PhpJsonError);
219
+ assert.throws(() => phpJsonEncode(Infinity), PhpJsonError);
220
+ assert.throws(() => phpJsonEncode(-Infinity), PhpJsonError);
221
+ });
222
+
223
+ test('unsupported values fail loud', () => {
224
+ assert.throws(() => phpJsonEncode(undefined), PhpJsonError);
225
+ assert.throws(() => phpJsonEncode(10n), PhpJsonError);
226
+ assert.throws(() => phpJsonEncode(new Date(0)), PhpJsonError);
227
+ assert.throws(() => phpJsonEncode({ a: new Set([1]) }), PhpJsonError);
228
+ assert.throws(() => phpJsonEncode('\ud800'), PhpJsonError);
229
+ assert.throws(() => phpJsonEncode('\udc00'), PhpJsonError);
230
+ });
231
+
232
+ test('documented normalization: integers beyond 2^53 encode as the double', () => {
233
+ assert.equal(phpJsonEncode(9223372036854775807), '"9.223372036854776e+18"'.slice(1, -1));
234
+ });
235
+
236
+ test('documented divergence: PHP json_decode loses -0 on decode', () => {
237
+ // PHP's encoder emits -0 for float -0.0, but its decoder maps the JSON
238
+ // token -0 back to int 0 (PHP is not round-trip stable here). JSON.parse
239
+ // keeps -0; the encode direction still matches PHP byte-for-byte.
240
+ assert.equal(phpJsonEncode(-0), '-0');
241
+ const results = runOracle('roundtrip', { strings: ['[-0]'] });
242
+ assert.ok(!results[0].err, results[0].err);
243
+ assert.equal(results[0].reencoded, '[0]');
244
+ assert.ok(Object.is(phpJsonDecode('[-0]')[0], -0));
245
+ });
246
+
247
+ test('phpJsonDecode reads PHP-produced payloads', () => {
248
+ assert.deepEqual(phpJsonDecode('{"a":[1,2],"b":{"c":null}}'), { a: [1, 2], b: { c: null } });
249
+ assert.deepEqual(phpJsonDecode('[]'), []);
250
+ });
251
+
252
+ test("a model is JsonSerializable (Laravel: json_encode($model) = its toArray())", () => {
253
+ // Laravel's Model implements JsonSerializable: json_encode($model) answers
254
+ // the model's toArray() (its toJSON()). The edge threw "unsupported value
255
+ // (Model)", so `response()->json($post)` was a 500.
256
+ class M extends Model {}
257
+ M.table = 'posts';
258
+ const model = new M({ id: 1, title: 'Hello' });
259
+ assert.equal(phpJsonEncode(model), '{"id":1,"title":"Hello"}');
260
+ // A Date has a toJSON() too - and PHP's json_encode(new DateTime()) answers
261
+ // '{}', so it stays unsupported here.
262
+ assert.throws(() => phpJsonEncode(new Date(0)), PhpJsonError);
263
+ });