apostrophe 4.30.0-alpha.1 → 4.30.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/.claude/settings.local.json +15 -0
- package/CHANGELOG.md +30 -2
- package/eslint.config.js +1 -2
- package/lib/mongodb-connect.js +62 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBar.vue +0 -1
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarMenu.vue +25 -8
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBreakpointPreviewMode.vue +9 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +20 -2
- package/modules/@apostrophecms/area/index.js +10 -5
- package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +2 -0
- package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +11 -1
- package/modules/@apostrophecms/db/index.js +27 -68
- package/modules/@apostrophecms/http/index.js +1 -1
- package/modules/@apostrophecms/i18n/i18n/en.json +8 -0
- package/modules/@apostrophecms/i18n/index.js +1 -8
- package/modules/@apostrophecms/image-widget/index.js +29 -1
- package/modules/@apostrophecms/job/index.js +7 -9
- package/modules/@apostrophecms/layout-widget/index.js +124 -2
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +89 -6
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +2 -2
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +2 -2
- package/modules/@apostrophecms/layout-widget/ui/apos/layout.css +8 -0
- package/modules/@apostrophecms/layout-widget/ui/src/layout.css +1 -1
- package/modules/@apostrophecms/layout-widget/views/widget.html +3 -3
- package/modules/@apostrophecms/login/index.js +13 -15
- package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +2 -1
- package/modules/@apostrophecms/oembed/index.js +18 -13
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +2 -0
- package/modules/@apostrophecms/rich-text-widget/index.js +36 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputCheckboxes.js +1 -1
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRadio.js +1 -1
- package/modules/@apostrophecms/styles/index.js +16 -0
- package/modules/@apostrophecms/styles/lib/handlers.js +6 -0
- package/modules/@apostrophecms/styles/lib/methods.js +93 -0
- package/modules/@apostrophecms/styles/lib/presets.js +17 -0
- package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +10 -1
- package/modules/@apostrophecms/submitted-draft/ui/apos/components/AposSubmittedDraftIcon.vue +1 -0
- package/modules/@apostrophecms/template/views/outerLayoutBase.html +1 -1
- package/modules/@apostrophecms/ui/ui/apos/components/AposButton.vue +14 -2
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +29 -5
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +8 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuItem.vue +5 -2
- package/modules/@apostrophecms/ui/ui/apos/components/AposLocalePicker.vue +14 -1
- package/modules/@apostrophecms/ui/ui/apos/scss/global/_utilities.scss +13 -1
- package/modules/@apostrophecms/util/index.js +4 -0
- package/modules/@apostrophecms/widget-type/index.js +6 -0
- package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +32 -0
- package/package.json +13 -13
- package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +131 -0
- package/test/add-missing-schema-fields-project/test.js +3 -11
- package/test/assets.js +67 -110
- package/test/db.js +15 -24
- package/test/job.js +1 -1
- package/test/layout-widget-gap.js +530 -0
- package/test/login.js +122 -1
- package/test/rich-text-widget.js +200 -0
- package/test/styles.js +50 -0
- package/test-lib/util.js +14 -50
- package/claude-tools/detect-handles.js +0 -46
- package/claude-tools/minimal-hang-test.js +0 -28
- package/claude-tools/mongo-close-test.js +0 -11
- package/claude-tools/stdin-ref-test.js +0 -14
- package/test/db-tools.js +0 -365
- package/test/default-adapter.js +0 -256
package/test/rich-text-widget.js
CHANGED
|
@@ -293,4 +293,204 @@ describe('Rich Text Widget', function () {
|
|
|
293
293
|
assert(text2.includes('src="/uploads/attachments/attachment-1-attachment-1.max.jpg" alt="Updated Test Image 1"'));
|
|
294
294
|
assert(text2.includes('src="/uploads/attachments/attachment-2-attachment-2.max.jpg" alt="Updated Test Image 2"'));
|
|
295
295
|
});
|
|
296
|
+
|
|
297
|
+
describe('image import allowlist (SSRF mitigation)', function () {
|
|
298
|
+
let originalConsoleError;
|
|
299
|
+
|
|
300
|
+
before(function () {
|
|
301
|
+
// The sanitize method intentionally console.errors thrown errors to
|
|
302
|
+
// surface stack traces during development; silence that noise during
|
|
303
|
+
// these negative tests.
|
|
304
|
+
// eslint-disable-next-line no-console
|
|
305
|
+
originalConsoleError = console.error;
|
|
306
|
+
// eslint-disable-next-line no-console
|
|
307
|
+
console.error = () => {};
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
after(function () {
|
|
311
|
+
// eslint-disable-next-line no-console
|
|
312
|
+
console.error = originalConsoleError;
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('allows rich text HTML import without `<img>` tags even with no allowlist configured', async function () {
|
|
316
|
+
apos = await t.create({
|
|
317
|
+
root: module,
|
|
318
|
+
modules: {}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const manager = apos.modules['@apostrophecms/rich-text-widget'];
|
|
322
|
+
const req = apos.task.getReq();
|
|
323
|
+
|
|
324
|
+
const output = await manager.sanitize(req, {
|
|
325
|
+
type: '@apostrophecms/rich-text',
|
|
326
|
+
content: '<p>seed</p>',
|
|
327
|
+
import: {
|
|
328
|
+
html: '<p>Hello <strong>world</strong></p>'
|
|
329
|
+
}
|
|
330
|
+
}, {});
|
|
331
|
+
|
|
332
|
+
assert.match(output.content, /Hello/);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('rejects an image fetch for a hostname not in the allowlist and never calls fetch', async function () {
|
|
336
|
+
apos = await t.create({
|
|
337
|
+
root: module,
|
|
338
|
+
modules: {
|
|
339
|
+
'@apostrophecms/rich-text-widget': {
|
|
340
|
+
options: {
|
|
341
|
+
imageImportAllowedHostnames: [ 'images.example.com' ]
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const manager = apos.modules['@apostrophecms/rich-text-widget'];
|
|
348
|
+
const req = apos.task.getReq();
|
|
349
|
+
|
|
350
|
+
const originalFetch = global.fetch;
|
|
351
|
+
let fetchCalled = false;
|
|
352
|
+
global.fetch = async () => {
|
|
353
|
+
fetchCalled = true;
|
|
354
|
+
throw new Error('fetch should not be called');
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
await assert.rejects(
|
|
359
|
+
() => manager.sanitize(req, {
|
|
360
|
+
type: '@apostrophecms/rich-text',
|
|
361
|
+
content: '<p>seed</p>',
|
|
362
|
+
import: {
|
|
363
|
+
html: '<img src="http://127.0.0.1:7777/secret.png">',
|
|
364
|
+
baseUrl: 'http://127.0.0.1:7777'
|
|
365
|
+
}
|
|
366
|
+
}, {}),
|
|
367
|
+
(err) => {
|
|
368
|
+
assert.equal(err.name, 'forbidden');
|
|
369
|
+
assert.match(err.message, /127\.0\.0\.1/);
|
|
370
|
+
assert.match(err.message, /imageImportAllowedHostnames/);
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
);
|
|
374
|
+
} finally {
|
|
375
|
+
global.fetch = originalFetch;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
assert.equal(fetchCalled, false);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('rejects rich text HTML import for non-http(s) protocols even if hostname matches', async function () {
|
|
382
|
+
apos = await t.create({
|
|
383
|
+
root: module,
|
|
384
|
+
modules: {
|
|
385
|
+
'@apostrophecms/rich-text-widget': {
|
|
386
|
+
options: {
|
|
387
|
+
imageImportAllowedHostnames: [ 'localhost' ]
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const manager = apos.modules['@apostrophecms/rich-text-widget'];
|
|
394
|
+
const req = apos.task.getReq();
|
|
395
|
+
|
|
396
|
+
await assert.rejects(
|
|
397
|
+
() => manager.sanitize(req, {
|
|
398
|
+
type: '@apostrophecms/rich-text',
|
|
399
|
+
content: '<p>seed</p>',
|
|
400
|
+
import: {
|
|
401
|
+
html: '<img src="file:///etc/passwd">',
|
|
402
|
+
baseUrl: 'file://localhost'
|
|
403
|
+
}
|
|
404
|
+
}, {}),
|
|
405
|
+
(err) => {
|
|
406
|
+
assert.equal(err.name, 'forbidden');
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('proceeds to fetch when import URL hostname is on the allowlist', async function () {
|
|
413
|
+
apos = await t.create({
|
|
414
|
+
root: module,
|
|
415
|
+
modules: {
|
|
416
|
+
'@apostrophecms/rich-text-widget': {
|
|
417
|
+
options: {
|
|
418
|
+
imageImportAllowedHostnames: [ 'images.example.com' ]
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const manager = apos.modules['@apostrophecms/rich-text-widget'];
|
|
425
|
+
const req = apos.task.getReq();
|
|
426
|
+
|
|
427
|
+
const originalFetch = global.fetch;
|
|
428
|
+
const fetchedUrls = [];
|
|
429
|
+
// Throw a unique marker error from inside fetch. If the allowlist
|
|
430
|
+
// passes, the marker error propagates; if the allowlist rejects, we
|
|
431
|
+
// would see a `forbidden` error instead.
|
|
432
|
+
const marker = new Error('FETCH_REACHED');
|
|
433
|
+
marker.name = 'FetchReached';
|
|
434
|
+
global.fetch = async (url) => {
|
|
435
|
+
fetchedUrls.push(url.toString());
|
|
436
|
+
throw marker;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
await assert.rejects(
|
|
441
|
+
() => manager.sanitize(req, {
|
|
442
|
+
type: '@apostrophecms/rich-text',
|
|
443
|
+
content: '<p>seed</p>',
|
|
444
|
+
import: {
|
|
445
|
+
html: '<img src="https://images.example.com/foo.png">',
|
|
446
|
+
baseUrl: 'https://images.example.com'
|
|
447
|
+
}
|
|
448
|
+
}, {}),
|
|
449
|
+
(err) => err.name === 'FetchReached'
|
|
450
|
+
);
|
|
451
|
+
} finally {
|
|
452
|
+
global.fetch = originalFetch;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
assert.deepEqual(fetchedUrls, [ 'https://images.example.com/foo.png' ]);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('helper methods reflect option configuration', async function () {
|
|
459
|
+
apos = await t.create({
|
|
460
|
+
root: module,
|
|
461
|
+
modules: {
|
|
462
|
+
'@apostrophecms/rich-text-widget': {
|
|
463
|
+
options: {
|
|
464
|
+
imageImportAllowedHostnames: [ 'Images.Example.com', '', null, 'cdn.example.com' ]
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const manager = apos.modules['@apostrophecms/rich-text-widget'];
|
|
471
|
+
|
|
472
|
+
assert.deepEqual(
|
|
473
|
+
manager.getImageImportAllowedHostnames(),
|
|
474
|
+
[ 'images.example.com', 'cdn.example.com' ]
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
const allowed = manager.getImageImportAllowedHostnames();
|
|
478
|
+
assert.equal(
|
|
479
|
+
manager.isImageImportHostnameAllowed(new URL('https://images.example.com/foo.png'), allowed),
|
|
480
|
+
true
|
|
481
|
+
);
|
|
482
|
+
assert.equal(
|
|
483
|
+
manager.isImageImportHostnameAllowed(new URL('https://IMAGES.example.com/foo.png'), allowed),
|
|
484
|
+
true
|
|
485
|
+
);
|
|
486
|
+
assert.equal(
|
|
487
|
+
manager.isImageImportHostnameAllowed(new URL('https://attacker.example.com/foo.png'), allowed),
|
|
488
|
+
false
|
|
489
|
+
);
|
|
490
|
+
assert.equal(
|
|
491
|
+
manager.isImageImportHostnameAllowed(new URL('file:///etc/passwd'), allowed),
|
|
492
|
+
false
|
|
493
|
+
);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
296
496
|
});
|
package/test/styles.js
CHANGED
|
@@ -1102,6 +1102,56 @@ describe('Styles', function () {
|
|
|
1102
1102
|
);
|
|
1103
1103
|
});
|
|
1104
1104
|
|
|
1105
|
+
it('should fall back to field.def when the doc has no value', async function () {
|
|
1106
|
+
const { renderGlobalStyles, renderScopedStyles } = await universal;
|
|
1107
|
+
|
|
1108
|
+
const schema = [
|
|
1109
|
+
{
|
|
1110
|
+
name: 'gap',
|
|
1111
|
+
type: 'range',
|
|
1112
|
+
selector: ':root',
|
|
1113
|
+
property: '--apos-layout-gap',
|
|
1114
|
+
unit: 'px',
|
|
1115
|
+
def: 24
|
|
1116
|
+
},
|
|
1117
|
+
{
|
|
1118
|
+
name: 'noDef',
|
|
1119
|
+
type: 'range',
|
|
1120
|
+
selector: ':root',
|
|
1121
|
+
property: '--no-def',
|
|
1122
|
+
unit: 'px'
|
|
1123
|
+
}
|
|
1124
|
+
];
|
|
1125
|
+
|
|
1126
|
+
// Doc carries no `gap` value (e.g. field added after doc was
|
|
1127
|
+
// created). The field's `def` is used.
|
|
1128
|
+
const fromDef = renderGlobalStyles(schema, {});
|
|
1129
|
+
assert.ok(
|
|
1130
|
+
fromDef.css.includes('--apos-layout-gap: 24px'),
|
|
1131
|
+
'renderGlobalStyles should emit field.def when doc value is absent'
|
|
1132
|
+
);
|
|
1133
|
+
assert.ok(
|
|
1134
|
+
!fromDef.css.includes('--no-def'),
|
|
1135
|
+
'fields without def should still be skipped'
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1138
|
+
// Saved value overrides def.
|
|
1139
|
+
const fromDoc = renderGlobalStyles(schema, { gap: 12 });
|
|
1140
|
+
assert.ok(
|
|
1141
|
+
fromDoc.css.includes('--apos-layout-gap: 12px'),
|
|
1142
|
+
'doc value should override field.def'
|
|
1143
|
+
);
|
|
1144
|
+
|
|
1145
|
+
// Scoped renderer behaves the same way.
|
|
1146
|
+
const scoped = renderScopedStyles(schema, {}, {
|
|
1147
|
+
rootSelector: '#id'
|
|
1148
|
+
});
|
|
1149
|
+
assert.ok(
|
|
1150
|
+
scoped.css.includes('--apos-layout-gap: 24px'),
|
|
1151
|
+
'renderScopedStyles should emit field.def when doc value is absent'
|
|
1152
|
+
);
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1105
1155
|
it('should include imageSizes in attachment getBrowserData', function () {
|
|
1106
1156
|
const browserData = apos.attachment.getBrowserData(apos.task.getReq());
|
|
1107
1157
|
assert.ok(
|
package/test-lib/util.js
CHANGED
|
@@ -1,27 +1,5 @@
|
|
|
1
1
|
const { createId } = require('@paralleldrive/cuid2');
|
|
2
|
-
|
|
3
|
-
const testDbProtocol = process.env.APOS_TEST_DB_PROTOCOL || 'mongodb';
|
|
4
|
-
|
|
5
|
-
// Build a test database URI for postgres based on the shortName.
|
|
6
|
-
// Returns undefined for mongodb, letting the default logic handle it.
|
|
7
|
-
function getTestDbUri(shortName) {
|
|
8
|
-
if (testDbProtocol === 'postgres') {
|
|
9
|
-
// PostgreSQL database names cannot contain hyphens
|
|
10
|
-
const dbName = shortName.replace(/-/g, '_');
|
|
11
|
-
return `postgres://localhost:5432/${dbName}`;
|
|
12
|
-
}
|
|
13
|
-
if (testDbProtocol === 'multipostgres') {
|
|
14
|
-
// Multi-schema mode: shared real database, per-test schema
|
|
15
|
-
const schemaName = shortName.replace(/-/g, '_').replace(/[^a-zA-Z0-9_]/g, '');
|
|
16
|
-
return `multipostgres://localhost:5432/apos_test-${schemaName}`;
|
|
17
|
-
}
|
|
18
|
-
if (testDbProtocol === 'sqlite') {
|
|
19
|
-
const os = require('os');
|
|
20
|
-
const path = require('path');
|
|
21
|
-
const dbName = shortName.replace(/-/g, '_').replace(/[^a-zA-Z0-9_]/g, '');
|
|
22
|
-
return `sqlite://${path.join(os.tmpdir(), `apos_test_${dbName}.db`)}`;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
2
|
+
const mongodbConnect = require('../lib/mongodb-connect');
|
|
25
3
|
|
|
26
4
|
// Properly clean up an apostrophe instance and drop its
|
|
27
5
|
// database collections to create a sane environment for the next test.
|
|
@@ -32,23 +10,23 @@ function getTestDbUri(shortName) {
|
|
|
32
10
|
// If `apos` is null, no work is done.
|
|
33
11
|
|
|
34
12
|
async function destroy(apos) {
|
|
35
|
-
if (!apos
|
|
13
|
+
if (!apos) {
|
|
36
14
|
return;
|
|
37
15
|
}
|
|
38
|
-
apos._destroyed = true;
|
|
39
|
-
const dbModule = apos.modules['@apostrophecms/db'];
|
|
40
|
-
const { uri } = dbModule;
|
|
41
|
-
const dbName = apos.db && (apos.db.databaseName || apos.db._name);
|
|
42
16
|
await apos.destroy();
|
|
43
|
-
|
|
44
|
-
|
|
17
|
+
const { uri } = apos.modules['@apostrophecms/db'];
|
|
18
|
+
const dbName = apos.db && apos.db.databaseName;
|
|
19
|
+
// TODO at some point accommodate nonsense like testing remote databases
|
|
20
|
+
// that won't let us use dropDatabase, no shell available etc., but the
|
|
21
|
+
// important principle here is that we should not have to have an apos
|
|
22
|
+
// object to clean up the database, otherwise we have to get hold of one
|
|
23
|
+
// when initialization failed and that's really not apostrophe's concern
|
|
24
|
+
if (dbName && uri) {
|
|
25
|
+
const client = await mongodbConnect(`${uri}${dbName}`);
|
|
26
|
+
const db = client.db(dbName);
|
|
27
|
+
await db.dropDatabase();
|
|
28
|
+
await client.close();
|
|
45
29
|
}
|
|
46
|
-
// Make a fresh connection (the original was closed by destroy)
|
|
47
|
-
// and use it to drop the test database
|
|
48
|
-
const client = await dbModule.connectToAdapter(uri);
|
|
49
|
-
const db = client.db(dbName);
|
|
50
|
-
await db.dropDatabase();
|
|
51
|
-
await client.close();
|
|
52
30
|
};
|
|
53
31
|
|
|
54
32
|
async function create(options = {}) {
|
|
@@ -77,18 +55,6 @@ async function create(options = {}) {
|
|
|
77
55
|
express.options.session.secret = express.options.session.secret || 'test';
|
|
78
56
|
config.modules['@apostrophecms/express'] = express;
|
|
79
57
|
}
|
|
80
|
-
// When APOS_TEST_DB_PROTOCOL=postgres, automatically configure the db
|
|
81
|
-
// module to use a postgres URI unless already explicitly configured
|
|
82
|
-
const testUri = getTestDbUri(config.shortName);
|
|
83
|
-
if (testUri) {
|
|
84
|
-
config.modules = config.modules || {};
|
|
85
|
-
const dbModule = config.modules['@apostrophecms/db'] || {};
|
|
86
|
-
dbModule.options = dbModule.options || {};
|
|
87
|
-
if (!dbModule.options.uri && !dbModule.options.client) {
|
|
88
|
-
dbModule.options.uri = testUri;
|
|
89
|
-
}
|
|
90
|
-
config.modules['@apostrophecms/db'] = dbModule;
|
|
91
|
-
}
|
|
92
58
|
return require('../index.js')(config);
|
|
93
59
|
}
|
|
94
60
|
|
|
@@ -185,7 +151,5 @@ module.exports = {
|
|
|
185
151
|
loginAs,
|
|
186
152
|
logout,
|
|
187
153
|
getUserJar,
|
|
188
|
-
getTestDbUri,
|
|
189
|
-
testDbProtocol,
|
|
190
154
|
timeout: (process.env.TEST_TIMEOUT && parseInt(process.env.TEST_TIMEOUT)) || 20000
|
|
191
155
|
};
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
// Require this before running mocha to detect what activates process.stdin
|
|
2
|
-
// Usage: npx mocha -t 10000 --require ./claude-tools/detect-handles.js test/assets.js
|
|
3
|
-
|
|
4
|
-
console.log(`stdin paused at startup: ${process.stdin.isPaused()}`);
|
|
5
|
-
console.log(`stdin readableFlowing at startup: ${process.stdin.readableFlowing}`);
|
|
6
|
-
|
|
7
|
-
// Monkey-patch stdin.resume to capture the call stack
|
|
8
|
-
const origResume = process.stdin.resume.bind(process.stdin);
|
|
9
|
-
process.stdin.resume = function(...args) {
|
|
10
|
-
console.log('\n=== process.stdin.resume() called ===');
|
|
11
|
-
console.log(new Error().stack);
|
|
12
|
-
return origResume(...args);
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
// Monkey-patch stdin.on to detect 'data' listener additions
|
|
16
|
-
const origOn = process.stdin.on.bind(process.stdin);
|
|
17
|
-
process.stdin.on = function(event, ...args) {
|
|
18
|
-
if (event === 'data' || event === 'readable') {
|
|
19
|
-
console.log(`\n=== process.stdin.on('${event}') called ===`);
|
|
20
|
-
console.log(new Error().stack);
|
|
21
|
-
}
|
|
22
|
-
return origOn(event, ...args);
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
// Periodically check stdin state changes
|
|
26
|
-
let lastState = process.stdin.readableFlowing;
|
|
27
|
-
const checker = setInterval(() => {
|
|
28
|
-
if (process.stdin.readableFlowing !== lastState) {
|
|
29
|
-
console.log(`\n=== stdin readableFlowing changed: ${lastState} -> ${process.stdin.readableFlowing} ===`);
|
|
30
|
-
console.log(new Error().stack);
|
|
31
|
-
lastState = process.stdin.readableFlowing;
|
|
32
|
-
}
|
|
33
|
-
}, 100);
|
|
34
|
-
checker.unref();
|
|
35
|
-
|
|
36
|
-
const origRun = require('mocha/lib/runner').prototype.run;
|
|
37
|
-
require('mocha/lib/runner').prototype.run = function(fn) {
|
|
38
|
-
return origRun.call(this, function(failures) {
|
|
39
|
-
console.log(`\nstdin paused at end: ${process.stdin.isPaused()}`);
|
|
40
|
-
console.log(`stdin readableFlowing at end: ${process.stdin.readableFlowing}`);
|
|
41
|
-
setTimeout(() => {
|
|
42
|
-
process.exit(failures ? 3 : 0);
|
|
43
|
-
}, 2000);
|
|
44
|
-
if (fn) fn(failures);
|
|
45
|
-
});
|
|
46
|
-
};
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
// Minimal test to isolate what causes the hang.
|
|
2
|
-
// Must reference the test/ directory as root for proper module resolution.
|
|
3
|
-
const t = require('../test-lib/test.js');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
|
|
6
|
-
// Fake a module object rooted in test/ like the real tests do
|
|
7
|
-
const fakeModule = {
|
|
8
|
-
id: path.join(__dirname, '../test/fake'),
|
|
9
|
-
filename: path.join(__dirname, '../test/fake.js'),
|
|
10
|
-
paths: [path.join(__dirname, '../test/node_modules')]
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
describe('Minimal hang test', function() {
|
|
14
|
-
this.timeout(60000);
|
|
15
|
-
let apos;
|
|
16
|
-
|
|
17
|
-
after(async function() {
|
|
18
|
-
await t.destroy(apos);
|
|
19
|
-
console.log('after: destroy complete');
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('should create and use apos without hanging', async function() {
|
|
23
|
-
apos = await t.create({
|
|
24
|
-
root: fakeModule
|
|
25
|
-
});
|
|
26
|
-
console.log('apos created successfully');
|
|
27
|
-
});
|
|
28
|
-
});
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
// Test whether a MongoDB connection keeps the process alive after close()
|
|
2
|
-
const mongoConnect = require('../../../packages/db-connect/lib/mongodb-connect');
|
|
3
|
-
|
|
4
|
-
(async () => {
|
|
5
|
-
const uri = 'mongodb://localhost:27017/test_handle_leak';
|
|
6
|
-
console.log('Connecting...');
|
|
7
|
-
const client = await mongoConnect(uri);
|
|
8
|
-
console.log('Connected. Closing...');
|
|
9
|
-
await client.close();
|
|
10
|
-
console.log('Closed. Process should exit now if no leaked handles.');
|
|
11
|
-
})();
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
// Check if process.stdin keeps the process alive
|
|
2
|
-
// If this script hangs, stdin is ref'd. If it exits, stdin is unref'd.
|
|
3
|
-
|
|
4
|
-
console.log(`stdin isTTY: ${process.stdin.isTTY}`);
|
|
5
|
-
console.log(`stdin readableFlowing: ${process.stdin.readableFlowing}`);
|
|
6
|
-
console.log(`stdin isPaused: ${process.stdin.isPaused()}`);
|
|
7
|
-
|
|
8
|
-
// Check ref status
|
|
9
|
-
if (typeof process.stdin.unref === 'function') {
|
|
10
|
-
console.log('stdin has unref method');
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
console.log('Waiting to see if process exits on its own...');
|
|
14
|
-
// Don't do anything - just see if the process exits
|