@remit/backend 0.0.105 → 0.0.107
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/package.json
CHANGED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an operator can tell from the log when the Microsoft OAuth start fails.
|
|
3
|
+
*
|
|
4
|
+
* Every credential failure answers the same 500 with the same body, so the
|
|
5
|
+
* backend log for that request is the only place the four are told apart: an
|
|
6
|
+
* instance with no Microsoft app registered must not read like a live outage.
|
|
7
|
+
* Driven through the real handler so what is asserted is the line a deployment
|
|
8
|
+
* actually writes, correlation id and all.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { after, before, describe, it } from "node:test";
|
|
14
|
+
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
|
|
15
|
+
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
16
|
+
import { captureStdout, restoreStdout } from "../stdout-capture-fixture.js";
|
|
17
|
+
|
|
18
|
+
const WEB_ORIGIN = "https://mail.example.com";
|
|
19
|
+
const START_PATH = "/accounts/oauth/microsoft/start";
|
|
20
|
+
const SECRET_ARN =
|
|
21
|
+
"arn:aws:secretsmanager:eu-west-1:000000000000:secret:msoauth-AbCdEf";
|
|
22
|
+
|
|
23
|
+
let handler: (
|
|
24
|
+
event: APIGatewayProxyEvent,
|
|
25
|
+
context: unknown,
|
|
26
|
+
) => Promise<APIGatewayProxyResult>;
|
|
27
|
+
|
|
28
|
+
const originalSend = SecretsManagerClient.prototype.send;
|
|
29
|
+
let secretString: string | undefined;
|
|
30
|
+
|
|
31
|
+
interface Outcome {
|
|
32
|
+
response: APIGatewayProxyResult;
|
|
33
|
+
log: string;
|
|
34
|
+
logged: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const headerOf = (
|
|
38
|
+
response: APIGatewayProxyResult,
|
|
39
|
+
name: string,
|
|
40
|
+
): string | undefined => {
|
|
41
|
+
for (const [key, value] of Object.entries(response.headers ?? {})) {
|
|
42
|
+
if (key.toLowerCase() === name) return String(value);
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const start = async (): Promise<Outcome> => {
|
|
48
|
+
const awsRequestId = `req-${randomUUID()}`;
|
|
49
|
+
const { result: response, logged } = await captureStdout(() =>
|
|
50
|
+
handler(
|
|
51
|
+
{
|
|
52
|
+
httpMethod: "POST",
|
|
53
|
+
path: START_PATH,
|
|
54
|
+
headers: {},
|
|
55
|
+
queryStringParameters: null,
|
|
56
|
+
body: JSON.stringify({ email: "matthijs@example.com" }),
|
|
57
|
+
requestContext: { authorizer: { claims: { sub: "cognito-sub" } } },
|
|
58
|
+
} as unknown as APIGatewayProxyEvent,
|
|
59
|
+
{ awsRequestId, functionName: "test" },
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
assert.equal(
|
|
64
|
+
headerOf(response, "x-correlation-id"),
|
|
65
|
+
awsRequestId,
|
|
66
|
+
"the id the client reports has to be the id the log lines carry",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const forThisRequest = logged
|
|
70
|
+
.split("\n")
|
|
71
|
+
.filter((line) => line.length > 0)
|
|
72
|
+
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
|
73
|
+
.filter((line) => line.requestId === awsRequestId);
|
|
74
|
+
|
|
75
|
+
assert.ok(
|
|
76
|
+
forThisRequest.length > 0,
|
|
77
|
+
"the failure left no line carrying the correlation id the client was handed",
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return { response, log: JSON.stringify(forThisRequest), logged };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The name each condition, and only that condition, puts in the log. */
|
|
84
|
+
const NO_APP_REGISTERED = "MicrosoftOAuthNotConfigured";
|
|
85
|
+
const SECRET_HAS_NO_STRING = "MicrosoftOAuthSecretEmpty";
|
|
86
|
+
const SECRET_MISSING_FIELDS = "MicrosoftOAuthSecretIncomplete";
|
|
87
|
+
const SECRET_IS_NOT_JSON = "MicrosoftOAuthSecretUnparseable";
|
|
88
|
+
|
|
89
|
+
const CONDITIONS = [
|
|
90
|
+
NO_APP_REGISTERED,
|
|
91
|
+
SECRET_HAS_NO_STRING,
|
|
92
|
+
SECRET_MISSING_FIELDS,
|
|
93
|
+
SECRET_IS_NOT_JSON,
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const assertNamesOnly = (log: string, condition: string): void => {
|
|
97
|
+
assert.ok(
|
|
98
|
+
log.includes(condition),
|
|
99
|
+
`the log for this request never names the condition ${condition}: ${log}`,
|
|
100
|
+
);
|
|
101
|
+
for (const other of CONDITIONS) {
|
|
102
|
+
if (other === condition) continue;
|
|
103
|
+
assert.ok(
|
|
104
|
+
!log.includes(other),
|
|
105
|
+
`the log also names ${other}, so the two conditions are indistinguishable`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const assertGenericFiveHundred = (response: APIGatewayProxyResult): void => {
|
|
111
|
+
assert.equal(response.statusCode, 500);
|
|
112
|
+
assert.deepEqual(JSON.parse(response.body), {
|
|
113
|
+
message: "Internal server error",
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
before(async () => {
|
|
118
|
+
process.env.LOG_LEVEL = "error";
|
|
119
|
+
process.env.DATA_BACKEND = "sqlite";
|
|
120
|
+
process.env.CORS_ALLOWED_ORIGINS = WEB_ORIGIN;
|
|
121
|
+
process.env.MSOAUTH_REDIRECT_URI = `${WEB_ORIGIN}/accounts/oauth/microsoft/callback`;
|
|
122
|
+
|
|
123
|
+
SecretsManagerClient.prototype.send = (async () => ({
|
|
124
|
+
SecretString: secretString,
|
|
125
|
+
})) as typeof SecretsManagerClient.prototype.send;
|
|
126
|
+
|
|
127
|
+
({ handler } = (await import("../index.js")) as unknown as {
|
|
128
|
+
handler: typeof handler;
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
after(() => {
|
|
133
|
+
SecretsManagerClient.prototype.send = originalSend;
|
|
134
|
+
restoreStdout();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("a Microsoft OAuth start that cannot load its credentials", () => {
|
|
138
|
+
it("names an instance with no Microsoft app registered", async () => {
|
|
139
|
+
delete process.env.MSOAUTH_CLIENT_ID;
|
|
140
|
+
delete process.env.MSOAUTH_CLIENT_SECRET;
|
|
141
|
+
delete process.env.MSOAUTH_SECRET_ARN;
|
|
142
|
+
|
|
143
|
+
const { response, log } = await start();
|
|
144
|
+
|
|
145
|
+
assertGenericFiveHundred(response);
|
|
146
|
+
assertNamesOnly(log, NO_APP_REGISTERED);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("names a secret that carries no string value", async () => {
|
|
150
|
+
delete process.env.MSOAUTH_CLIENT_ID;
|
|
151
|
+
delete process.env.MSOAUTH_CLIENT_SECRET;
|
|
152
|
+
process.env.MSOAUTH_SECRET_ARN = SECRET_ARN;
|
|
153
|
+
secretString = undefined;
|
|
154
|
+
|
|
155
|
+
const { response, log } = await start();
|
|
156
|
+
|
|
157
|
+
assertGenericFiveHundred(response);
|
|
158
|
+
assertNamesOnly(log, SECRET_HAS_NO_STRING);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("names a secret whose credential fields are absent", async () => {
|
|
162
|
+
delete process.env.MSOAUTH_CLIENT_ID;
|
|
163
|
+
delete process.env.MSOAUTH_CLIENT_SECRET;
|
|
164
|
+
process.env.MSOAUTH_SECRET_ARN = SECRET_ARN;
|
|
165
|
+
secretString = JSON.stringify({ clientId: "an-app-id" });
|
|
166
|
+
|
|
167
|
+
const { response, log } = await start();
|
|
168
|
+
|
|
169
|
+
assertGenericFiveHundred(response);
|
|
170
|
+
assertNamesOnly(log, SECRET_MISSING_FIELDS);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("names a secret that is not JSON without quoting what it holds", async () => {
|
|
174
|
+
const secretMaterial = "s3cr3t-not-json-9d41c0b7";
|
|
175
|
+
delete process.env.MSOAUTH_CLIENT_ID;
|
|
176
|
+
delete process.env.MSOAUTH_CLIENT_SECRET;
|
|
177
|
+
process.env.MSOAUTH_SECRET_ARN = SECRET_ARN;
|
|
178
|
+
secretString = secretMaterial;
|
|
179
|
+
|
|
180
|
+
const { response, log, logged } = await start();
|
|
181
|
+
|
|
182
|
+
assertGenericFiveHundred(response);
|
|
183
|
+
assertNamesOnly(log, SECRET_IS_NOT_JSON);
|
|
184
|
+
assert.equal(
|
|
185
|
+
logged.includes(secretMaterial),
|
|
186
|
+
false,
|
|
187
|
+
"a parse failure quoting the secret into the log turns a misconfiguration into a leak",
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -123,6 +123,21 @@ function getSecretsManagerClient(): SecretsManagerClient {
|
|
|
123
123
|
return smClient;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
type MsOAuthCredentialFailure =
|
|
127
|
+
| "MicrosoftOAuthNotConfigured"
|
|
128
|
+
| "MicrosoftOAuthSecretEmpty"
|
|
129
|
+
| "MicrosoftOAuthSecretIncomplete"
|
|
130
|
+
| "MicrosoftOAuthSecretUnparseable";
|
|
131
|
+
|
|
132
|
+
function credentialFailure(
|
|
133
|
+
name: MsOAuthCredentialFailure,
|
|
134
|
+
message: string,
|
|
135
|
+
): Error {
|
|
136
|
+
const error = new Error(message);
|
|
137
|
+
error.name = name;
|
|
138
|
+
return error;
|
|
139
|
+
}
|
|
140
|
+
|
|
126
141
|
async function getMsOAuthCredentials(): Promise<MsOAuthCredentials> {
|
|
127
142
|
const config = getMsOAuthConfig();
|
|
128
143
|
|
|
@@ -133,8 +148,9 @@ async function getMsOAuthCredentials(): Promise<MsOAuthCredentials> {
|
|
|
133
148
|
|
|
134
149
|
// Production: fetch from Secrets Manager
|
|
135
150
|
if (!config.secretArn) {
|
|
136
|
-
throw
|
|
137
|
-
"
|
|
151
|
+
throw credentialFailure(
|
|
152
|
+
"MicrosoftOAuthNotConfigured",
|
|
153
|
+
"No Microsoft OAuth credentials are configured on this instance: set MSOAUTH_CLIENT_ID and MSOAUTH_CLIENT_SECRET, or MSOAUTH_SECRET_ARN",
|
|
138
154
|
);
|
|
139
155
|
}
|
|
140
156
|
|
|
@@ -144,13 +160,29 @@ async function getMsOAuthCredentials(): Promise<MsOAuthCredentials> {
|
|
|
144
160
|
);
|
|
145
161
|
|
|
146
162
|
if (!result.SecretString) {
|
|
147
|
-
throw
|
|
163
|
+
throw credentialFailure(
|
|
164
|
+
"MicrosoftOAuthSecretEmpty",
|
|
165
|
+
"The Microsoft OAuth secret named by MSOAUTH_SECRET_ARN holds no SecretString",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const parsed = await safeJsonParse<MsOAuthCredentials>(
|
|
170
|
+
result.SecretString,
|
|
171
|
+
).catch(() => null);
|
|
172
|
+
if (!parsed) {
|
|
173
|
+
throw credentialFailure(
|
|
174
|
+
"MicrosoftOAuthSecretUnparseable",
|
|
175
|
+
"The Microsoft OAuth secret named by MSOAUTH_SECRET_ARN does not hold a JSON object",
|
|
176
|
+
);
|
|
148
177
|
}
|
|
149
178
|
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
179
|
+
const absent = (["clientId", "clientSecret"] as const).filter(
|
|
180
|
+
(field) => !parsed[field],
|
|
181
|
+
);
|
|
182
|
+
if (absent.length > 0) {
|
|
183
|
+
throw credentialFailure(
|
|
184
|
+
"MicrosoftOAuthSecretIncomplete",
|
|
185
|
+
`The Microsoft OAuth secret named by MSOAUTH_SECRET_ARN is missing ${absent.join(" and ")}`,
|
|
154
186
|
);
|
|
155
187
|
}
|
|
156
188
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import { MessagePlacementUnsettledError } from "@remit/data-ports/errors";
|
|
3
4
|
import {
|
|
4
5
|
MoveNotSettledError,
|
|
5
6
|
NoJunkMailboxError,
|
|
@@ -41,6 +42,32 @@ describe("settleSpamReportBulk", () => {
|
|
|
41
42
|
assert.notEqual(reason, GENERIC_FAILURE_REASON);
|
|
42
43
|
});
|
|
43
44
|
|
|
45
|
+
// A refusal the move gate raises used to collapse to the generic retry copy,
|
|
46
|
+
// which is a dead end for the reason no retry clears (#665).
|
|
47
|
+
it("words a placement refusal from its reason, never from the server's sentence", async () => {
|
|
48
|
+
const outcome = await settleSpamReportBulk(
|
|
49
|
+
["msg-1", "msg-2"],
|
|
50
|
+
async (messageId) => {
|
|
51
|
+
throw new MessagePlacementUnsettledError(
|
|
52
|
+
`Message ${messageId} was not acted on: its folder and uid do not name the same message`,
|
|
53
|
+
"acc-1",
|
|
54
|
+
messageId,
|
|
55
|
+
messageId === "msg-1" ? "in_flight" : "unverified",
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const [inFlight, unverified] = outcome.failures?.map((f) => f.reason) ?? [];
|
|
61
|
+
assert.match(inFlight ?? "", /try again in a moment/i);
|
|
62
|
+
assert.match(unverified ?? "", /sync the folder/i);
|
|
63
|
+
assert.doesNotMatch(unverified ?? "", /try again in a moment/i);
|
|
64
|
+
for (const reason of [inFlight, unverified]) {
|
|
65
|
+
assert.notEqual(reason, GENERIC_FAILURE_REASON);
|
|
66
|
+
assert.doesNotMatch(reason ?? "", /folder and uid/);
|
|
67
|
+
assert.doesNotMatch(reason ?? "", /acc-1/);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
44
71
|
it("omits failures entirely when every message succeeds", async () => {
|
|
45
72
|
const outcome = await settleSpamReportBulk(
|
|
46
73
|
["msg-1", "msg-2"],
|
package/src/handlers/message.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { MailboxItem } from "@remit/data-ports";
|
|
|
7
7
|
import {
|
|
8
8
|
BadRequestError,
|
|
9
9
|
ForbiddenError,
|
|
10
|
+
MessagePlacementUnsettledError,
|
|
10
11
|
NotFoundError,
|
|
11
12
|
UnrecoverableBodyError,
|
|
12
13
|
} from "@remit/data-ports/errors";
|
|
@@ -356,13 +357,35 @@ export const GENERIC_FAILURE_REASON =
|
|
|
356
357
|
* each names what happened and what the user can do about it, which the
|
|
357
358
|
* generic text cannot.
|
|
358
359
|
*/
|
|
359
|
-
const DESIGNED_FAILURES = [
|
|
360
|
+
const DESIGNED_FAILURES = [
|
|
361
|
+
MoveNotSettledError,
|
|
362
|
+
NoJunkMailboxError,
|
|
363
|
+
MessagePlacementUnsettledError,
|
|
364
|
+
];
|
|
360
365
|
|
|
361
366
|
const isDesignedFailure = (reason: unknown): reason is Error =>
|
|
362
367
|
DESIGNED_FAILURES.some((designed) => reason instanceof designed);
|
|
363
368
|
|
|
364
|
-
|
|
365
|
-
|
|
369
|
+
/**
|
|
370
|
+
* The placement refusal is the one designed outcome whose own message is not
|
|
371
|
+
* the text to ship: it names a uuid and no remedy, and the two reasons do not
|
|
372
|
+
* share a remedy — an in-flight move settles on its own, an abandoned one only
|
|
373
|
+
* clears once the folder is resynced. Worded here off `details.reason`, the way
|
|
374
|
+
* the delete path words the same refusal for the client.
|
|
375
|
+
*/
|
|
376
|
+
const placementRefusalReason = (
|
|
377
|
+
error: MessagePlacementUnsettledError,
|
|
378
|
+
): string =>
|
|
379
|
+
error.publicApiError?.details?.reason === "unverified"
|
|
380
|
+
? "An earlier move of this message never finished, so where it sits is unknown. Sync the folder, then report it again."
|
|
381
|
+
: "This message is still being moved on the mail server. Try again in a moment.";
|
|
382
|
+
|
|
383
|
+
const failureReason = (reason: unknown): string => {
|
|
384
|
+
if (!isDesignedFailure(reason)) return GENERIC_FAILURE_REASON;
|
|
385
|
+
if (reason instanceof MessagePlacementUnsettledError)
|
|
386
|
+
return placementRefusalReason(reason);
|
|
387
|
+
return reason.message;
|
|
388
|
+
};
|
|
366
389
|
|
|
367
390
|
/**
|
|
368
391
|
* Drive one report-spam/not-spam operation per message, concurrently. Each
|
|
@@ -11,25 +11,7 @@
|
|
|
11
11
|
import assert from "node:assert/strict";
|
|
12
12
|
import { after, before, describe, it } from "node:test";
|
|
13
13
|
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
14
|
-
|
|
15
|
-
// pino writes to `process.stdout` only when its `write` has been replaced, so
|
|
16
|
-
// the hook goes in before the logger is imported — which every import below
|
|
17
|
-
// does transitively. Writes pass through whenever nothing is capturing, so the
|
|
18
|
-
// test runner's own output still reaches the terminal.
|
|
19
|
-
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
20
|
-
const written: string[] = [];
|
|
21
|
-
let capturing = false;
|
|
22
|
-
|
|
23
|
-
process.stdout.write = ((
|
|
24
|
-
chunk: string | Uint8Array,
|
|
25
|
-
...rest: unknown[]
|
|
26
|
-
): boolean => {
|
|
27
|
-
if (capturing && typeof chunk === "string") {
|
|
28
|
-
written.push(chunk);
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
return (originalWrite as (...args: unknown[]) => boolean)(chunk, ...rest);
|
|
32
|
-
}) as typeof process.stdout.write;
|
|
14
|
+
import { captureStdout, restoreStdout } from "./stdout-capture-fixture.js";
|
|
33
15
|
|
|
34
16
|
process.env.LOG_LEVEL = "trace";
|
|
35
17
|
|
|
@@ -45,19 +27,6 @@ let handler: (
|
|
|
45
27
|
) => Promise<APIGatewayProxyResult>;
|
|
46
28
|
let cleanup: () => void;
|
|
47
29
|
|
|
48
|
-
const capture = async (
|
|
49
|
-
run: () => Promise<APIGatewayProxyResult>,
|
|
50
|
-
): Promise<{ response: APIGatewayProxyResult; logged: string }> => {
|
|
51
|
-
written.length = 0;
|
|
52
|
-
capturing = true;
|
|
53
|
-
try {
|
|
54
|
-
const response = await run();
|
|
55
|
-
return { response, logged: written.join("") };
|
|
56
|
-
} finally {
|
|
57
|
-
capturing = false;
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
|
|
61
30
|
const send = (
|
|
62
31
|
event: Partial<APIGatewayProxyEvent> & { httpMethod: string; path: string },
|
|
63
32
|
): Promise<APIGatewayProxyResult> =>
|
|
@@ -85,7 +54,7 @@ before(async () => {
|
|
|
85
54
|
|
|
86
55
|
after(() => {
|
|
87
56
|
_resetForTest();
|
|
88
|
-
|
|
57
|
+
restoreStdout();
|
|
89
58
|
cleanup();
|
|
90
59
|
});
|
|
91
60
|
|
|
@@ -93,7 +62,7 @@ describe("what a request leaves in the log", () => {
|
|
|
93
62
|
it("never writes the feed token a subscriber polls with", async () => {
|
|
94
63
|
const { token } = mintCalendarFeedToken();
|
|
95
64
|
|
|
96
|
-
const { response, logged } = await
|
|
65
|
+
const { result: response, logged } = await captureStdout(() =>
|
|
97
66
|
send({ httpMethod: "GET", path: `/feeds/calendar/${token}.ics` }),
|
|
98
67
|
);
|
|
99
68
|
|
|
@@ -119,7 +88,7 @@ describe("what a request leaves in the log", () => {
|
|
|
119
88
|
// request reaches validation — where the whole parsed request, headers and
|
|
120
89
|
// all, used to be logged. The delete refuses because it declares
|
|
121
90
|
// `calendarId` as a required query parameter and none was sent.
|
|
122
|
-
const { response, logged } = await
|
|
91
|
+
const { result: response, logged } = await captureStdout(() =>
|
|
123
92
|
send({
|
|
124
93
|
httpMethod: "DELETE",
|
|
125
94
|
path: "/calendar-events/8f14e45f-ceea-467a-9c9e-9c9e9c9e9c9e",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A test's view of what a request wrote to the log.
|
|
3
|
+
*
|
|
4
|
+
* pino writes through `process.stdout` once its `write` is replaced, so the hook
|
|
5
|
+
* is installed when this module is evaluated — import it above anything that
|
|
6
|
+
* pulls in the logger. Writes pass through whenever nothing is capturing, so the
|
|
7
|
+
* test runner's own output still reaches the terminal.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
11
|
+
const written: string[] = [];
|
|
12
|
+
let capturing = false;
|
|
13
|
+
|
|
14
|
+
process.stdout.write = ((
|
|
15
|
+
chunk: string | Uint8Array,
|
|
16
|
+
...rest: unknown[]
|
|
17
|
+
): boolean => {
|
|
18
|
+
if (capturing && typeof chunk === "string") {
|
|
19
|
+
written.push(chunk);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
return (originalWrite as (...args: unknown[]) => boolean)(chunk, ...rest);
|
|
23
|
+
}) as typeof process.stdout.write;
|
|
24
|
+
|
|
25
|
+
export const captureStdout = async <T>(
|
|
26
|
+
run: () => Promise<T>,
|
|
27
|
+
): Promise<{ result: T; logged: string }> => {
|
|
28
|
+
written.length = 0;
|
|
29
|
+
capturing = true;
|
|
30
|
+
try {
|
|
31
|
+
const result = await run();
|
|
32
|
+
return { result, logged: written.join("") };
|
|
33
|
+
} finally {
|
|
34
|
+
capturing = false;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const restoreStdout = (): void => {
|
|
39
|
+
process.stdout.write = originalWrite as typeof process.stdout.write;
|
|
40
|
+
};
|