Skip to content

Commit c86cca5

Browse files
committed
PoC for logger
1 parent 20e83fc commit c86cca5

File tree

5 files changed

+616
-56
lines changed

5 files changed

+616
-56
lines changed

.github/workflows/ci.yml

Lines changed: 7 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,30 @@
11
name: Continuous Integration
22

3-
on:
4-
pull_request:
5-
branches:
6-
- main
7-
push:
8-
branches:
9-
- main
3+
on: push
104

115
permissions:
126
contents: read
137

148
jobs:
15-
test-typescript:
16-
name: TypeScript Tests
9+
logger:
10+
name: Logger PoC
1711
runs-on: ubuntu-latest
1812

1913
steps:
2014
- name: Checkout
21-
id: checkout
2215
uses: actions/checkout@v4
2316

2417
- name: Setup Node.js
25-
id: setup-node
2618
uses: actions/setup-node@v4
2719
with:
2820
node-version-file: .node-version
2921
cache: npm
3022

3123
- name: Install Dependencies
32-
id: npm-ci
3324
run: npm ci
3425

35-
- name: Check Format
36-
id: npm-format-check
37-
run: npm run format:check
26+
- name: Logger
27+
run: npx tsx usage.ts
3828

39-
- name: Lint
40-
id: npm-lint
41-
run: npm run lint
42-
43-
- name: Test
44-
id: npm-ci-test
45-
run: npm run ci-test
46-
47-
test-action:
48-
name: GitHub Actions Test
49-
runs-on: ubuntu-latest
50-
51-
permissions:
52-
pull-requests: write
53-
54-
steps:
55-
- name: Checkout
56-
id: checkout
57-
uses: actions/checkout@v4
58-
59-
- name: Setup Node.js
60-
id: setup-node
61-
uses: actions/setup-node@v4
62-
with:
63-
node-version-file: .node-version
64-
cache: npm
65-
66-
- name: Install Dependencies
67-
id: npm-ci
68-
run: npm ci
69-
70-
- name: Test Local Action
71-
id: test-action
72-
uses: ./
73-
env:
74-
CP_SERVER: ${{ secrets.CP_SERVER }}
75-
CP_API_KEY: ${{ secrets.CP_API_KEY }}
76-
77-
- name: Print Output
78-
id: output
79-
run: echo "${{ steps.test-action.outputs.comment-id }}"
29+
- name: Logger (verbose)
30+
run: npx tsx usage.ts --verbose

logger.ts

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
import ansis, { type AnsiColors } from 'ansis';
2+
import { platform } from 'node:os';
3+
import ora, { type Ora } from 'ora';
4+
5+
type GroupColor = Extract<AnsiColors, 'cyan' | 'magenta'>;
6+
type CiPlatform = 'GitHub Actions' | 'GitLab CI/CD';
7+
8+
const GROUP_COLOR_ENV_VAR_NAME = 'CP_LOGGER_GROUP_COLOR';
9+
10+
export class Logger {
11+
// TODO: smart boolean parsing
12+
#isVerbose = process.env['CP_VERBOSE'] === 'true';
13+
#isCI = process.env['CI'] === 'true';
14+
#ciPlatform: CiPlatform | undefined =
15+
process.env['GITHUB_ACTIONS'] === 'true'
16+
? 'GitHub Actions'
17+
: process.env['GITLAB_CI'] === 'true'
18+
? 'GitLab CI/CD'
19+
: undefined;
20+
#groupColor: GroupColor | undefined =
21+
process.env[GROUP_COLOR_ENV_VAR_NAME] === 'cyan' ||
22+
process.env[GROUP_COLOR_ENV_VAR_NAME] === 'magenta'
23+
? process.env[GROUP_COLOR_ENV_VAR_NAME]
24+
: undefined;
25+
26+
#groupsCount = 0;
27+
#activeSpinner: Ora | undefined;
28+
#activeSpinnerLogs: string[] = [];
29+
#endsWithBlankLine = false;
30+
31+
#groupSymbols = {
32+
start: '❯',
33+
middle: '│',
34+
end: '└',
35+
};
36+
37+
#sigintListener = () => {
38+
if (this.#activeSpinner != null) {
39+
const text = `${this.#activeSpinner.text} ${ansis.red.bold('[SIGINT]')}`;
40+
if (this.#groupColor) {
41+
this.#activeSpinner.stopAndPersist({
42+
text,
43+
symbol: this.#colorize(this.#groupSymbols.end, this.#groupColor),
44+
});
45+
this.#setGroupColor(undefined);
46+
} else {
47+
this.#activeSpinner.fail(text);
48+
}
49+
this.#activeSpinner = undefined;
50+
}
51+
this.newline();
52+
this.error(ansis.bold('Cancelled by SIGINT'));
53+
process.exit(platform() === 'win32' ? 2 : 130);
54+
};
55+
56+
error(message: string): void {
57+
this.#log(message, 'red');
58+
}
59+
60+
warn(message: string): void {
61+
this.#log(message, 'yellow');
62+
}
63+
64+
info(message: string): void {
65+
this.#log(message);
66+
}
67+
68+
debug(message: string): void {
69+
if (this.#isVerbose) {
70+
this.#log(message, 'gray');
71+
}
72+
}
73+
74+
newline(): void {
75+
this.#log('');
76+
}
77+
78+
isVerbose(): boolean {
79+
return this.#isVerbose;
80+
}
81+
82+
setVerbose(isVerbose: boolean): void {
83+
process.env['CP_VERBOSE'] = `${isVerbose}`;
84+
this.#isVerbose = isVerbose;
85+
}
86+
87+
async group(title: string, worker: () => Promise<string>): Promise<void> {
88+
if (!this.#endsWithBlankLine) {
89+
this.newline();
90+
}
91+
92+
this.#setGroupColor(this.#groupsCount % 2 === 0 ? 'cyan' : 'magenta');
93+
this.#groupsCount++;
94+
95+
const groupMarkers = this.#createGroupMarkers();
96+
97+
console.log(groupMarkers.start(title));
98+
99+
const start = performance.now();
100+
const result = await this.#settlePromise(worker());
101+
const end = performance.now();
102+
103+
if (result.status === 'fulfilled') {
104+
console.log(
105+
[
106+
this.#colorize(this.#groupSymbols.end, this.#groupColor),
107+
this.#colorize(result.value, 'green'),
108+
this.#formatDuration({ start, end }),
109+
].join(' '),
110+
);
111+
} else {
112+
console.log(
113+
[
114+
this.#colorize(this.#groupSymbols.end, this.#groupColor),
115+
this.#colorize(`${result.reason}`, 'red'),
116+
].join(' '),
117+
);
118+
}
119+
120+
const endMarker = groupMarkers.end();
121+
if (endMarker) {
122+
console.log(endMarker);
123+
}
124+
this.#setGroupColor(undefined);
125+
this.newline();
126+
127+
if (result.status === 'rejected') {
128+
throw result.reason;
129+
}
130+
}
131+
132+
#createGroupMarkers(): {
133+
start: (title: string) => string;
134+
end: () => string;
135+
} {
136+
const formatTitle = (title: string) =>
137+
ansis.bold(this.#colorize(title, this.#groupColor));
138+
139+
switch (this.#ciPlatform) {
140+
case 'GitHub Actions':
141+
// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#grouping-log-lines
142+
return {
143+
start: title => `::group::${formatTitle(title)}`,
144+
end: () => '::endgroup::',
145+
};
146+
case 'GitLab CI/CD':
147+
// https://docs.gitlab.com/ci/jobs/job_logs/#custom-collapsible-sections
148+
const unixTimestamp = () => Math.round(Date.now() / 1000);
149+
const ansiEscCode = '\x1b[0K'; // '\e' ESC character only works for `echo -e`, Node console must use '\x1b'
150+
const id = Math.random().toString(16).slice(2);
151+
const sectionId = `code_pushup_logs_group_${id}`;
152+
return {
153+
start: title => {
154+
const sectionHeader = formatTitle(
155+
`${this.#groupSymbols.start} ${title}`,
156+
);
157+
const options = this.#isVerbose ? '' : '[collapsed=true]';
158+
return `${ansiEscCode}section_start:${unixTimestamp()}:${sectionId}${options}\r${ansiEscCode}${sectionHeader}`;
159+
},
160+
end: () =>
161+
`${ansiEscCode}section_end:${unixTimestamp()}:${sectionId}\r${ansiEscCode}`,
162+
};
163+
case undefined:
164+
return {
165+
start: title => formatTitle(`${this.#groupSymbols.start} ${title}`),
166+
end: () => '',
167+
};
168+
}
169+
}
170+
171+
#setGroupColor(groupColor: GroupColor | undefined) {
172+
this.#groupColor = groupColor;
173+
if (groupColor) {
174+
process.env[GROUP_COLOR_ENV_VAR_NAME] = groupColor;
175+
} else {
176+
delete process.env[GROUP_COLOR_ENV_VAR_NAME];
177+
}
178+
}
179+
180+
task(title: string, worker: () => Promise<string>): Promise<void> {
181+
return this.#spinner(worker, {
182+
pending: title,
183+
success: value => value,
184+
failure: error => `${title}${ansis.red(`${error}`)}`,
185+
});
186+
}
187+
188+
command(bin: string, worker: () => Promise<void>): Promise<void> {
189+
return this.#spinner(worker, {
190+
pending: `${ansis.blue('$')} ${bin}`,
191+
success: () => `${ansis.green('$')} ${bin}`,
192+
failure: () => `${ansis.red('$')} ${bin}`,
193+
});
194+
}
195+
196+
async #spinner<T>(
197+
worker: () => Promise<T>,
198+
messages: {
199+
pending: string;
200+
success: (value: T) => string;
201+
failure: (error: unknown) => string;
202+
},
203+
): Promise<void> {
204+
process.removeListener('SIGINT', this.#sigintListener);
205+
process.addListener('SIGINT', this.#sigintListener);
206+
207+
if (this.#groupColor) {
208+
this.#activeSpinner = ora({
209+
text: this.#isCI
210+
? `\r${this.#format(messages.pending, undefined)}`
211+
: messages.pending,
212+
spinner: 'line',
213+
color: this.#groupColor,
214+
});
215+
} else {
216+
this.#activeSpinner = ora(messages.pending);
217+
}
218+
219+
this.#activeSpinner.start();
220+
this.#endsWithBlankLine = false;
221+
222+
const start = performance.now();
223+
const result = await this.#settlePromise(worker());
224+
const end = performance.now();
225+
226+
const text =
227+
result.status === 'fulfilled'
228+
? [
229+
messages.success(result.value),
230+
this.#formatDuration({ start, end }),
231+
].join(' ')
232+
: messages.failure(result.reason);
233+
234+
if (this.#groupColor) {
235+
this.#activeSpinner.stopAndPersist({
236+
text,
237+
symbol: this.#colorize(this.#groupSymbols.middle, this.#groupColor),
238+
});
239+
} else {
240+
if (result.status === 'fulfilled') {
241+
this.#activeSpinner.succeed(text);
242+
} else {
243+
this.#activeSpinner.fail(text);
244+
}
245+
}
246+
this.#endsWithBlankLine = false;
247+
248+
this.#activeSpinner = undefined;
249+
this.#activeSpinnerLogs.forEach(message => {
250+
this.#log(` ${message}`);
251+
});
252+
this.#activeSpinnerLogs = [];
253+
process.removeListener('SIGINT', this.#sigintListener);
254+
255+
if (result.status === 'rejected') {
256+
throw result.reason;
257+
}
258+
}
259+
260+
#log(message: string, color?: AnsiColors): void {
261+
if (this.#activeSpinner) {
262+
if (this.#activeSpinner.isSpinning) {
263+
this.#activeSpinnerLogs.push(this.#format(message, color));
264+
} else {
265+
console.log(this.#format(` ${message}`, color));
266+
}
267+
} else {
268+
console.log(this.#format(message, color));
269+
}
270+
this.#endsWithBlankLine = !message || message.endsWith('\n');
271+
}
272+
273+
#format(message: string, color: AnsiColors | undefined): string {
274+
if (!this.#groupColor || this.#activeSpinner?.isSpinning) {
275+
return this.#colorize(message, color);
276+
}
277+
return message
278+
.split(/\r?\n/)
279+
.map(line =>
280+
[
281+
this.#colorize('│', this.#groupColor),
282+
this.#colorize(line, color),
283+
].join(' '),
284+
)
285+
.join('\n');
286+
}
287+
288+
#colorize(text: string, color: AnsiColors | undefined): string {
289+
if (!color) {
290+
return text;
291+
}
292+
return ansis[color](text);
293+
}
294+
295+
#formatDuration({ start, end }: { start: number; end: number }): string {
296+
const duration = end - start;
297+
const seconds = Math.round(duration / 10) / 100;
298+
return ansis.gray(`(${seconds}s)`);
299+
}
300+
301+
async #settlePromise<T>(
302+
promise: Promise<T>,
303+
): Promise<PromiseSettledResult<T>> {
304+
try {
305+
const value = await promise;
306+
return { status: 'fulfilled', value };
307+
} catch (error) {
308+
return { status: 'rejected', reason: error };
309+
}
310+
}
311+
}

0 commit comments

Comments
 (0)