@freegamestore/cli 0.1.4
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/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/commands/check.d.ts +14 -0
- package/dist/commands/check.d.ts.map +1 -0
- package/dist/commands/check.js +65 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/doctor.d.ts +3 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +34 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.d.ts +22 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +137 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/list.d.ts +9 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +96 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/list.test.d.ts +2 -0
- package/dist/commands/list.test.d.ts.map +1 -0
- package/dist/commands/list.test.js +25 -0
- package/dist/commands/list.test.js.map +1 -0
- package/dist/commands/login.d.ts +11 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +50 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +3 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +12 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/logs.d.ts +3 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/logs.js +33 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/publish.d.ts +42 -0
- package/dist/commands/publish.d.ts.map +1 -0
- package/dist/commands/publish.js +372 -0
- package/dist/commands/publish.js.map +1 -0
- package/dist/commands/publish.test.d.ts +2 -0
- package/dist/commands/publish.test.d.ts.map +1 -0
- package/dist/commands/publish.test.js +166 -0
- package/dist/commands/publish.test.js.map +1 -0
- package/dist/commands/screencheck.d.ts +36 -0
- package/dist/commands/screencheck.d.ts.map +1 -0
- package/dist/commands/screencheck.js +428 -0
- package/dist/commands/screencheck.js.map +1 -0
- package/dist/commands/whoami.d.ts +3 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +13 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/app-id.d.ts +3 -0
- package/dist/lib/app-id.d.ts.map +1 -0
- package/dist/lib/app-id.js +16 -0
- package/dist/lib/app-id.js.map +1 -0
- package/dist/lib/app-id.test.d.ts +2 -0
- package/dist/lib/app-id.test.d.ts.map +1 -0
- package/dist/lib/app-id.test.js +45 -0
- package/dist/lib/app-id.test.js.map +1 -0
- package/dist/lib/apps.d.ts +36 -0
- package/dist/lib/apps.d.ts.map +1 -0
- package/dist/lib/apps.js +21 -0
- package/dist/lib/apps.js.map +1 -0
- package/dist/lib/apps.test.d.ts +2 -0
- package/dist/lib/apps.test.d.ts.map +1 -0
- package/dist/lib/apps.test.js +42 -0
- package/dist/lib/apps.test.js.map +1 -0
- package/dist/lib/config.d.ts +26 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +33 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/config.test.d.ts +2 -0
- package/dist/lib/config.test.d.ts.map +1 -0
- package/dist/lib/config.test.js +22 -0
- package/dist/lib/config.test.js.map +1 -0
- package/dist/lib/github.d.ts +11 -0
- package/dist/lib/github.d.ts.map +1 -0
- package/dist/lib/github.js +62 -0
- package/dist/lib/github.js.map +1 -0
- package/dist/lib/history.d.ts +29 -0
- package/dist/lib/history.d.ts.map +1 -0
- package/dist/lib/history.js +77 -0
- package/dist/lib/history.js.map +1 -0
- package/dist/lib/history.test.d.ts +2 -0
- package/dist/lib/history.test.d.ts.map +1 -0
- package/dist/lib/history.test.js +30 -0
- package/dist/lib/history.test.js.map +1 -0
- package/dist/lib/open.d.ts +6 -0
- package/dist/lib/open.d.ts.map +1 -0
- package/dist/lib/open.js +31 -0
- package/dist/lib/open.js.map +1 -0
- package/dist/lib/style.d.ts +9 -0
- package/dist/lib/style.d.ts.map +1 -0
- package/dist/lib/style.js +18 -0
- package/dist/lib/style.js.map +1 -0
- package/dist/one-shot/doctor.d.ts +13 -0
- package/dist/one-shot/doctor.d.ts.map +1 -0
- package/dist/one-shot/doctor.js +116 -0
- package/dist/one-shot/doctor.js.map +1 -0
- package/dist/one-shot/doctor.test.d.ts +2 -0
- package/dist/one-shot/doctor.test.d.ts.map +1 -0
- package/dist/one-shot/doctor.test.js +63 -0
- package/dist/one-shot/doctor.test.js.map +1 -0
- package/package.json +48 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseGitHubRepo, buildSubmissionUrl, resolveCategory, resolveType, resolveFromFlags, buildPromptList, } from './publish.js';
|
|
3
|
+
describe('parseGitHubRepo', () => {
|
|
4
|
+
it('parses HTTPS clone URLs', () => {
|
|
5
|
+
expect(parseGitHubRepo('https://github.com/foo/bar')).toBe('foo/bar');
|
|
6
|
+
expect(parseGitHubRepo('https://github.com/foo/bar.git')).toBe('foo/bar');
|
|
7
|
+
expect(parseGitHubRepo('https://github.com/foo/bar/')).toBe('foo/bar');
|
|
8
|
+
expect(parseGitHubRepo('https://github.com/foo/bar.git/')).toBe('foo/bar');
|
|
9
|
+
});
|
|
10
|
+
it('parses SSH clone URLs', () => {
|
|
11
|
+
expect(parseGitHubRepo('git@github.com:foo/bar.git')).toBe('foo/bar');
|
|
12
|
+
expect(parseGitHubRepo('git@github.com:foo/bar')).toBe('foo/bar');
|
|
13
|
+
});
|
|
14
|
+
it('allows dots in repo names (e.g. node.js, foo.config)', () => {
|
|
15
|
+
expect(parseGitHubRepo('https://github.com/nodejs/node.js')).toBe('nodejs/node.js');
|
|
16
|
+
expect(parseGitHubRepo('git@github.com:nodejs/node.js.git')).toBe('nodejs/node.js');
|
|
17
|
+
});
|
|
18
|
+
it('allows hyphens, underscores, and digits', () => {
|
|
19
|
+
expect(parseGitHubRepo('https://github.com/foo-bar/baz_qux-2')).toBe('foo-bar/baz_qux-2');
|
|
20
|
+
});
|
|
21
|
+
it('returns null for non-GitHub URLs', () => {
|
|
22
|
+
expect(parseGitHubRepo('https://gitlab.com/foo/bar')).toBeNull();
|
|
23
|
+
expect(parseGitHubRepo('https://example.com/repo')).toBeNull();
|
|
24
|
+
expect(parseGitHubRepo('not a url')).toBeNull();
|
|
25
|
+
expect(parseGitHubRepo('')).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('buildSubmissionUrl', () => {
|
|
29
|
+
const baseInput = {
|
|
30
|
+
name: 'my-app',
|
|
31
|
+
category: 'Productivity',
|
|
32
|
+
type: 'Standalone (no backend, localStorage only)',
|
|
33
|
+
oneliner: 'A quick way to track tasks',
|
|
34
|
+
description: 'Detailed description here.',
|
|
35
|
+
repo: null,
|
|
36
|
+
demo: null,
|
|
37
|
+
};
|
|
38
|
+
it('builds a github.com submission URL with the right template', () => {
|
|
39
|
+
const url = new URL(buildSubmissionUrl(baseInput));
|
|
40
|
+
expect(url.host).toBe('github.com');
|
|
41
|
+
expect(url.pathname).toBe('/freeappstore-online/submissions/issues/new');
|
|
42
|
+
expect(url.searchParams.get('template')).toBe('app-submission.yml');
|
|
43
|
+
});
|
|
44
|
+
it('prefills every required template field', () => {
|
|
45
|
+
const url = new URL(buildSubmissionUrl(baseInput));
|
|
46
|
+
expect(url.searchParams.get('name')).toBe('my-app');
|
|
47
|
+
expect(url.searchParams.get('category')).toBe('Productivity');
|
|
48
|
+
expect(url.searchParams.get('type')).toBe(baseInput.type);
|
|
49
|
+
expect(url.searchParams.get('oneliner')).toBe('A quick way to track tasks');
|
|
50
|
+
expect(url.searchParams.get('description')).toBe('Detailed description here.');
|
|
51
|
+
expect(url.searchParams.get('title')).toBe('[Submission] my-app');
|
|
52
|
+
});
|
|
53
|
+
it('includes repo when present, omits when null', () => {
|
|
54
|
+
const withRepo = new URL(buildSubmissionUrl({ ...baseInput, repo: 'https://github.com/me/x' }));
|
|
55
|
+
expect(withRepo.searchParams.get('repo')).toBe('https://github.com/me/x');
|
|
56
|
+
const without = new URL(buildSubmissionUrl(baseInput));
|
|
57
|
+
expect(without.searchParams.has('repo')).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
it('includes demo when present, omits when null', () => {
|
|
60
|
+
const withDemo = new URL(buildSubmissionUrl({ ...baseInput, demo: 'https://demo.example' }));
|
|
61
|
+
expect(withDemo.searchParams.get('demo')).toBe('https://demo.example');
|
|
62
|
+
const without = new URL(buildSubmissionUrl(baseInput));
|
|
63
|
+
expect(without.searchParams.has('demo')).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
it('properly URL-encodes special characters in description', () => {
|
|
66
|
+
const url = new URL(buildSubmissionUrl({
|
|
67
|
+
...baseInput,
|
|
68
|
+
description: 'A "quoted" thing & more (with parens)',
|
|
69
|
+
}));
|
|
70
|
+
expect(url.searchParams.get('description')).toBe('A "quoted" thing & more (with parens)');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('resolveCategory', () => {
|
|
74
|
+
it('matches exact label', () => {
|
|
75
|
+
expect(resolveCategory('Productivity')).toBe('Productivity');
|
|
76
|
+
});
|
|
77
|
+
it('matches case-insensitive', () => {
|
|
78
|
+
expect(resolveCategory('utilities')).toBe('Utilities');
|
|
79
|
+
expect(resolveCategory('UTILITIES')).toBe('Utilities');
|
|
80
|
+
expect(resolveCategory(' Brain Training ')).toBe('Brain Training');
|
|
81
|
+
});
|
|
82
|
+
it('matches "other" short form', () => {
|
|
83
|
+
expect(resolveCategory('other')).toBe('Other (specify in description)');
|
|
84
|
+
});
|
|
85
|
+
it('returns null for unknown', () => {
|
|
86
|
+
expect(resolveCategory('nope')).toBeNull();
|
|
87
|
+
expect(resolveCategory('')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('resolveType', () => {
|
|
91
|
+
it('matches short forms', () => {
|
|
92
|
+
expect(resolveType('standalone')).toBe('Standalone (no backend, localStorage only)');
|
|
93
|
+
expect(resolveType('connected')).toBe('Connected (Firebase/Supabase backend, shared with Pro version)');
|
|
94
|
+
});
|
|
95
|
+
it('matches case-insensitive full label', () => {
|
|
96
|
+
expect(resolveType('STANDALONE')).toBe('Standalone (no backend, localStorage only)');
|
|
97
|
+
});
|
|
98
|
+
it('returns null for unknown', () => {
|
|
99
|
+
expect(resolveType('something')).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('resolveFromFlags', () => {
|
|
103
|
+
it('returns empty values when no flags supplied', () => {
|
|
104
|
+
const r = resolveFromFlags({});
|
|
105
|
+
expect(r.values).toEqual({});
|
|
106
|
+
expect(r.errors).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
it('resolves valid combinations', () => {
|
|
109
|
+
const r = resolveFromFlags({
|
|
110
|
+
name: 'my-app',
|
|
111
|
+
category: 'utilities',
|
|
112
|
+
type: 'standalone',
|
|
113
|
+
oneliner: 'Does a thing',
|
|
114
|
+
demo: 'https://demo.example',
|
|
115
|
+
});
|
|
116
|
+
expect(r.errors).toEqual([]);
|
|
117
|
+
expect(r.values.name).toBe('my-app');
|
|
118
|
+
expect(r.values.category).toBe('Utilities');
|
|
119
|
+
expect(r.values.type).toBe('Standalone (no backend, localStorage only)');
|
|
120
|
+
expect(r.values.oneliner).toBe('Does a thing');
|
|
121
|
+
expect(r.values.demo).toBe('https://demo.example');
|
|
122
|
+
});
|
|
123
|
+
it('treats blank --demo as null', () => {
|
|
124
|
+
const r = resolveFromFlags({ demo: ' ' });
|
|
125
|
+
expect(r.values.demo).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
it('rejects invalid app id', () => {
|
|
128
|
+
const r = resolveFromFlags({ name: 'BadName' });
|
|
129
|
+
expect(r.errors[0]).toMatch(/--name/);
|
|
130
|
+
expect(r.values.name).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
it('rejects unknown category and type', () => {
|
|
133
|
+
const r = resolveFromFlags({ category: 'nope', type: 'foo' });
|
|
134
|
+
expect(r.errors).toHaveLength(2);
|
|
135
|
+
});
|
|
136
|
+
it('rejects empty oneliner', () => {
|
|
137
|
+
const r = resolveFromFlags({ oneliner: ' ' });
|
|
138
|
+
expect(r.errors[0]).toMatch(/oneliner/);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
describe('buildPromptList', () => {
|
|
142
|
+
const defaults = { appName: null, description: null };
|
|
143
|
+
it('returns all 5 prompts when nothing resolved', () => {
|
|
144
|
+
expect(buildPromptList({}, defaults).map((p) => p.name)).toEqual([
|
|
145
|
+
'name',
|
|
146
|
+
'category',
|
|
147
|
+
'type',
|
|
148
|
+
'oneliner',
|
|
149
|
+
'demo',
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
it('skips a prompt when its value is already resolved', () => {
|
|
153
|
+
const list = buildPromptList({ name: 'my-app', category: 'Utilities', type: 'Standalone (no backend, localStorage only)' }, defaults);
|
|
154
|
+
expect(list.map((p) => p.name)).toEqual(['oneliner', 'demo']);
|
|
155
|
+
});
|
|
156
|
+
it('returns empty list when everything resolved', () => {
|
|
157
|
+
expect(buildPromptList({
|
|
158
|
+
name: 'x',
|
|
159
|
+
category: 'Utilities',
|
|
160
|
+
type: 'Standalone (no backend, localStorage only)',
|
|
161
|
+
oneliner: 'y',
|
|
162
|
+
demo: null,
|
|
163
|
+
}, defaults)).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
//# sourceMappingURL=publish.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"publish.test.js","sourceRoot":"","sources":["../../src/commands/publish.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
interface ViewportTest {
|
|
3
|
+
label: string;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}
|
|
7
|
+
export declare const screencheckCommand: Command;
|
|
8
|
+
interface ViewportTestExpanded extends ViewportTest {
|
|
9
|
+
share: number;
|
|
10
|
+
orientation: 'portrait' | 'landscape';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Pick the full reference matrix to test, gated by manifest orientation.
|
|
14
|
+
* For orientation='any', test both. For 'portrait'/'landscape', test only
|
|
15
|
+
* that side. Sizes below `minWidth` are still tested — failing below the
|
|
16
|
+
* declared min is *expected*, but if it passes, the creator can claim a
|
|
17
|
+
* wider device coverage than they declared.
|
|
18
|
+
*/
|
|
19
|
+
export declare function pickTests(minWidth: number, orientation: string): ViewportTest[];
|
|
20
|
+
export declare function pickMatrix(_minWidth: number, orientation: string): ViewportTestExpanded[];
|
|
21
|
+
/**
|
|
22
|
+
* Given the matrix and pass/fail per size, compute device coverage.
|
|
23
|
+
* Coverage = max share among passing widths, per orientation.
|
|
24
|
+
*
|
|
25
|
+
* Why max(share): share is cumulative-from-this-width-up. The smallest
|
|
26
|
+
* passing width has the highest share; passing larger widths is implied
|
|
27
|
+
* by passing the smallest. We pick the lowest passing width's share.
|
|
28
|
+
*/
|
|
29
|
+
export declare function computeCoverage(matrix: ViewportTestExpanded[], passing: Set<string>): {
|
|
30
|
+
portrait: number;
|
|
31
|
+
landscape: number;
|
|
32
|
+
overall: number;
|
|
33
|
+
brokenSizes: ViewportTestExpanded[];
|
|
34
|
+
};
|
|
35
|
+
export {};
|
|
36
|
+
//# sourceMappingURL=screencheck.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"screencheck.d.ts","sourceRoot":"","sources":["../../src/commands/screencheck.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAkDpC,UAAU,YAAY;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAoCD,eAAO,MAAM,kBAAkB,SAyI3B,CAAC;AAgCL,UAAU,oBAAqB,SAAQ,YAAY;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,UAAU,GAAG,WAAW,CAAC;CACvC;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,YAAY,EAAE,CAM/E;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,oBAAoB,EAAE,CA4BzF;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,oBAAoB,EAAE,EAC9B,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,GACnB;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,oBAAoB,EAAE,CAAA;CAAE,CAsB/F"}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
5
|
+
import { extname, join, resolve, normalize } from 'node:path';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
const DEFAULT_PORT = 4571;
|
|
10
|
+
const MIME = {
|
|
11
|
+
'.html': 'text/html; charset=utf-8',
|
|
12
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
13
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
14
|
+
'.css': 'text/css; charset=utf-8',
|
|
15
|
+
'.json': 'application/json; charset=utf-8',
|
|
16
|
+
'.svg': 'image/svg+xml',
|
|
17
|
+
'.png': 'image/png',
|
|
18
|
+
'.ico': 'image/x-icon',
|
|
19
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
20
|
+
'.webp': 'image/webp',
|
|
21
|
+
'.woff': 'font/woff',
|
|
22
|
+
'.woff2': 'font/woff2',
|
|
23
|
+
};
|
|
24
|
+
export const screencheckCommand = new Command('screencheck')
|
|
25
|
+
.description('Run a real browser at the manifest-declared viewport in portrait + landscape and verify the app fits without scrolling.')
|
|
26
|
+
.option('--dir <path>', 'Repo dir to check (defaults to cwd).', process.cwd())
|
|
27
|
+
.option('--port <n>', `Static-server port (default ${DEFAULT_PORT}).`, String(DEFAULT_PORT))
|
|
28
|
+
.option('--skip-build', 'Skip `pnpm build` — assume web/dist is current.', false)
|
|
29
|
+
.option('--screenshots', 'Save a PNG per viewport to ./screencheck-out/ for visual review.', false)
|
|
30
|
+
.option('--url <url>', 'Check a live URL instead of the local build.')
|
|
31
|
+
.action(async (raw) => {
|
|
32
|
+
const opts = {
|
|
33
|
+
dir: raw.dir,
|
|
34
|
+
port: Number(raw.port),
|
|
35
|
+
skipBuild: Boolean(raw.skipBuild),
|
|
36
|
+
screenshots: Boolean(raw.screenshots),
|
|
37
|
+
url: raw.url ?? null,
|
|
38
|
+
};
|
|
39
|
+
const playwright = await loadPlaywright(opts.dir);
|
|
40
|
+
if (!playwright) {
|
|
41
|
+
process.stdout.write('\n⚠ Playwright not installed.\n' +
|
|
42
|
+
' Run: pnpm add -D playwright && npx playwright install chromium\n' +
|
|
43
|
+
' Then re-run: fas screencheck\n');
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
// For --url, skip the manifest read and use safe defaults.
|
|
47
|
+
let minWidth;
|
|
48
|
+
let orientation;
|
|
49
|
+
if (opts.url) {
|
|
50
|
+
minWidth = 320;
|
|
51
|
+
orientation = 'any';
|
|
52
|
+
process.stdout.write(`\nChecking ${opts.url} (live URL — manifest not consulted).\n`);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
const manifest = await readManifest(opts.dir);
|
|
56
|
+
if (!manifest) {
|
|
57
|
+
process.stdout.write('\n✗ web/public/manifest.json not found or unparseable.\n');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
minWidth = typeof manifest['min_viewport_width'] === 'number' ? manifest['min_viewport_width'] : 320;
|
|
61
|
+
orientation = typeof manifest['orientation'] === 'string' ? manifest['orientation'] : 'any';
|
|
62
|
+
process.stdout.write(`\nManifest: orientation=${orientation} · min ${minWidth}px wide\n`);
|
|
63
|
+
}
|
|
64
|
+
const matrix = pickMatrix(minWidth, orientation);
|
|
65
|
+
if (matrix.length === 0) {
|
|
66
|
+
process.stdout.write('\n✗ Manifest orientation is invalid or no test sizes apply.\n');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
process.stdout.write(`Testing ${matrix.length} reference viewports across the device matrix.\n`);
|
|
70
|
+
let url;
|
|
71
|
+
let server = null;
|
|
72
|
+
if (opts.url) {
|
|
73
|
+
url = opts.url;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
if (!opts.skipBuild) {
|
|
77
|
+
process.stdout.write('\nBuilding web/dist…\n');
|
|
78
|
+
await runShell('pnpm', ['build'], opts.dir);
|
|
79
|
+
}
|
|
80
|
+
const distDir = resolve(opts.dir, 'web', 'dist');
|
|
81
|
+
if (!existsSync(distDir)) {
|
|
82
|
+
process.stdout.write(`\n✗ ${distDir} doesn't exist. Run \`pnpm build\` first.\n`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
server = await startServer(distDir, opts.port);
|
|
86
|
+
url = `http://localhost:${opts.port}/`;
|
|
87
|
+
process.stdout.write(`Serving ${distDir} at ${url}\n\n`);
|
|
88
|
+
}
|
|
89
|
+
const shotsDir = resolve(opts.dir, 'screencheck-out');
|
|
90
|
+
if (opts.screenshots) {
|
|
91
|
+
const { mkdirSync } = await import('node:fs');
|
|
92
|
+
mkdirSync(shotsDir, { recursive: true });
|
|
93
|
+
process.stdout.write(`Saving screenshots to ${shotsDir}\n\n`);
|
|
94
|
+
}
|
|
95
|
+
let exitCode = 0;
|
|
96
|
+
try {
|
|
97
|
+
const browser = await playwright.launch();
|
|
98
|
+
const results = [];
|
|
99
|
+
const passing = new Set();
|
|
100
|
+
for (const t of matrix) {
|
|
101
|
+
const r = await measure(browser, url, t, opts.screenshots ? shotsDir : null);
|
|
102
|
+
results.push(r);
|
|
103
|
+
renderResult(r);
|
|
104
|
+
// Pass = no document scroll AND no inner clipping.
|
|
105
|
+
if (!r.scrollsX && !r.scrollsY && r.clippingElements.length === 0) {
|
|
106
|
+
passing.add(t.label);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
await browser.close();
|
|
110
|
+
const cov = computeCoverage(matrix, passing);
|
|
111
|
+
const failed = results.filter((r) => r.scrollsX || r.scrollsY || r.clippingElements.length > 0).length;
|
|
112
|
+
process.stdout.write('\n');
|
|
113
|
+
renderCoverage(cov, matrix);
|
|
114
|
+
process.stdout.write('\n');
|
|
115
|
+
if (failed > 0) {
|
|
116
|
+
process.stdout.write(`✗ ${failed}/${results.length} reference viewports have layout issues.\n`);
|
|
117
|
+
// Coverage failure is a fail. But: failing only at the very
|
|
118
|
+
// top end of the matrix (1024+ desktop) above what the manifest
|
|
119
|
+
// claims doesn't necessarily warrant a non-zero exit; the
|
|
120
|
+
// creator can opt out via orientation=portrait, and the badge
|
|
121
|
+
// already conveys reality. So we exit non-zero only if the
|
|
122
|
+
// *declared* min fails.
|
|
123
|
+
const declaredMinFails = matrix.some((t) => t.width === minWidth && !passing.has(t.label));
|
|
124
|
+
if (declaredMinFails) {
|
|
125
|
+
process.stdout.write(` At least one failing viewport is at or below your declared min_viewport_width (${minWidth}px).\n`);
|
|
126
|
+
process.stdout.write(' Either fix the layout or raise min_viewport_width in your manifest.\n');
|
|
127
|
+
exitCode = 1;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
process.stdout.write(` All failures are above your declared min (${minWidth}px). Consider raising it to claim coverage that's actually true.\n`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
process.stdout.write(`✓ All ${results.length} reference viewports fit cleanly.\n`);
|
|
135
|
+
const minPassing = Math.min(...matrix.filter((t) => passing.has(t.label)).map((t) => t.width));
|
|
136
|
+
if (minPassing < minWidth) {
|
|
137
|
+
process.stdout.write(` You could lower min_viewport_width to ${minPassing} — your app actually fits there.\n`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
server?.close();
|
|
143
|
+
}
|
|
144
|
+
process.exit(exitCode);
|
|
145
|
+
});
|
|
146
|
+
/**
|
|
147
|
+
* The reference device matrix. Each entry pairs a width with a real-world
|
|
148
|
+
* device class and the *cumulative device share* at that width — used to
|
|
149
|
+
* answer "what % of users does this break for?". Numbers match the
|
|
150
|
+
* storefront's viewport-coverage badge so the CLI and the badge agree.
|
|
151
|
+
*
|
|
152
|
+
* Cumulative means: `share` is the % of devices whose viewport is at
|
|
153
|
+
* LEAST this wide. So 360px = 96% means 96% of devices have ≥360px
|
|
154
|
+
* available; if the app fails at 360, you've lost those 96%.
|
|
155
|
+
*
|
|
156
|
+
* Source: blended StatCounter + caniuse share-of-screens, rounded.
|
|
157
|
+
*/
|
|
158
|
+
const REFERENCE_PORTRAIT = [
|
|
159
|
+
{ width: 320, height: 568, label: 'iPhone SE (1st gen)', share: 99 },
|
|
160
|
+
{ width: 360, height: 800, label: 'Android baseline', share: 96 },
|
|
161
|
+
{ width: 414, height: 896, label: 'iPhone 11/Pro Max', share: 88 },
|
|
162
|
+
{ width: 600, height: 800, label: 'Small tablet', share: 60 },
|
|
163
|
+
{ width: 768, height: 1024, label: 'iPad portrait', share: 35 },
|
|
164
|
+
{ width: 1024, height: 1366, label: 'iPad Pro portrait', share: 20 },
|
|
165
|
+
];
|
|
166
|
+
const REFERENCE_LANDSCAPE = [
|
|
167
|
+
{ width: 568, height: 320, label: 'iPhone SE landscape', share: 99 },
|
|
168
|
+
{ width: 667, height: 375, label: 'iPhone 8 landscape', share: 96 },
|
|
169
|
+
{ width: 736, height: 414, label: 'iPhone Plus landscape', share: 88 },
|
|
170
|
+
{ width: 800, height: 600, label: 'Small tablet landscape', share: 60 },
|
|
171
|
+
{ width: 1024, height: 768, label: 'iPad landscape', share: 35 },
|
|
172
|
+
{ width: 1366, height: 1024, label: 'iPad Pro landscape', share: 20 },
|
|
173
|
+
];
|
|
174
|
+
/**
|
|
175
|
+
* Pick the full reference matrix to test, gated by manifest orientation.
|
|
176
|
+
* For orientation='any', test both. For 'portrait'/'landscape', test only
|
|
177
|
+
* that side. Sizes below `minWidth` are still tested — failing below the
|
|
178
|
+
* declared min is *expected*, but if it passes, the creator can claim a
|
|
179
|
+
* wider device coverage than they declared.
|
|
180
|
+
*/
|
|
181
|
+
export function pickTests(minWidth, orientation) {
|
|
182
|
+
return pickMatrix(minWidth, orientation).map(({ orientation: o, ...t }) => ({
|
|
183
|
+
label: t.label,
|
|
184
|
+
width: t.width,
|
|
185
|
+
height: t.height,
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
export function pickMatrix(_minWidth, orientation) {
|
|
189
|
+
const isPortrait = orientation === 'portrait' || orientation === 'portrait-primary';
|
|
190
|
+
const isLandscape = orientation === 'landscape' || orientation === 'landscape-primary';
|
|
191
|
+
const isAny = orientation === 'any' || orientation === 'unspecified' || !orientation;
|
|
192
|
+
const matrix = [];
|
|
193
|
+
if (isPortrait || isAny) {
|
|
194
|
+
for (const r of REFERENCE_PORTRAIT) {
|
|
195
|
+
matrix.push({
|
|
196
|
+
label: `portrait ${r.width}×${r.height} (${r.label})`,
|
|
197
|
+
width: r.width,
|
|
198
|
+
height: r.height,
|
|
199
|
+
share: r.share,
|
|
200
|
+
orientation: 'portrait',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (isLandscape || isAny) {
|
|
205
|
+
for (const r of REFERENCE_LANDSCAPE) {
|
|
206
|
+
matrix.push({
|
|
207
|
+
label: `landscape ${r.width}×${r.height} (${r.label})`,
|
|
208
|
+
width: r.width,
|
|
209
|
+
height: r.height,
|
|
210
|
+
share: r.share,
|
|
211
|
+
orientation: 'landscape',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return matrix;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Given the matrix and pass/fail per size, compute device coverage.
|
|
219
|
+
* Coverage = max share among passing widths, per orientation.
|
|
220
|
+
*
|
|
221
|
+
* Why max(share): share is cumulative-from-this-width-up. The smallest
|
|
222
|
+
* passing width has the highest share; passing larger widths is implied
|
|
223
|
+
* by passing the smallest. We pick the lowest passing width's share.
|
|
224
|
+
*/
|
|
225
|
+
export function computeCoverage(matrix, passing) {
|
|
226
|
+
let portrait = 0;
|
|
227
|
+
let landscape = 0;
|
|
228
|
+
const broken = [];
|
|
229
|
+
for (const t of matrix) {
|
|
230
|
+
if (passing.has(t.label)) {
|
|
231
|
+
if (t.orientation === 'portrait')
|
|
232
|
+
portrait = Math.max(portrait, t.share);
|
|
233
|
+
else
|
|
234
|
+
landscape = Math.max(landscape, t.share);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
broken.push(t);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// For 'any' orientation, the user needs BOTH to work, so coverage is
|
|
241
|
+
// the lower of the two. For one-orientation apps, only that side
|
|
242
|
+
// matters.
|
|
243
|
+
const hasPortrait = matrix.some((t) => t.orientation === 'portrait');
|
|
244
|
+
const hasLandscape = matrix.some((t) => t.orientation === 'landscape');
|
|
245
|
+
let overall = 0;
|
|
246
|
+
if (hasPortrait && hasLandscape)
|
|
247
|
+
overall = Math.min(portrait, landscape);
|
|
248
|
+
else if (hasPortrait)
|
|
249
|
+
overall = portrait;
|
|
250
|
+
else
|
|
251
|
+
overall = landscape;
|
|
252
|
+
return { portrait, landscape, overall, brokenSizes: broken };
|
|
253
|
+
}
|
|
254
|
+
async function measure(browser, url, t, shotsDir) {
|
|
255
|
+
// Cast through unknown — we only call a tiny subset of the Page API
|
|
256
|
+
// and don't want to take a Playwright type dep at module load time.
|
|
257
|
+
const page = (await browser.newPage({ viewport: { width: t.width, height: t.height } }));
|
|
258
|
+
await page.goto(url, { waitUntil: 'networkidle' });
|
|
259
|
+
// Small settle delay — fonts, images, late-load JS.
|
|
260
|
+
await page.evaluate(() => new Promise((r) => setTimeout(r, 250)));
|
|
261
|
+
// Bundle the document-level metrics + inner-clipping scan into one
|
|
262
|
+
// page.evaluate, written as a string so TS doesn't try to type-check
|
|
263
|
+
// browser globals like `document` and `getComputedStyle`. This catches
|
|
264
|
+
// the common case where a layout uses `overflow:hidden` on a parent
|
|
265
|
+
// to mask an oversized child — visually content is cropped, but the
|
|
266
|
+
// document doesn't scroll, so a naive scrollWidth check passes.
|
|
267
|
+
const dim = await page.evaluate(new Function(`
|
|
268
|
+
const root = document.documentElement;
|
|
269
|
+
const TOL = 1;
|
|
270
|
+
const clipping = [];
|
|
271
|
+
const elements = document.querySelectorAll('*');
|
|
272
|
+
for (let i = 0; i < elements.length; i++) {
|
|
273
|
+
const el = elements[i];
|
|
274
|
+
const cs = getComputedStyle(el);
|
|
275
|
+
const ovx = cs.overflowX;
|
|
276
|
+
const ovy = cs.overflowY;
|
|
277
|
+
const xClipped = (ovx === 'hidden' || ovx === 'clip') && el.scrollWidth > el.clientWidth + TOL;
|
|
278
|
+
const yClipped = (ovy === 'hidden' || ovy === 'clip') && el.scrollHeight > el.clientHeight + TOL;
|
|
279
|
+
if (xClipped || yClipped) {
|
|
280
|
+
clipping.push({
|
|
281
|
+
tag: el.tagName.toLowerCase(),
|
|
282
|
+
cls: (el.className || '').toString().slice(0, 50),
|
|
283
|
+
id: el.id || null,
|
|
284
|
+
scrollW: el.scrollWidth,
|
|
285
|
+
scrollH: el.scrollHeight,
|
|
286
|
+
clientW: el.clientWidth,
|
|
287
|
+
clientH: el.clientHeight,
|
|
288
|
+
clipsX: xClipped,
|
|
289
|
+
clipsY: yClipped,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
scrollWidth: root.scrollWidth,
|
|
295
|
+
scrollHeight: root.scrollHeight,
|
|
296
|
+
clientWidth: root.clientWidth,
|
|
297
|
+
clientHeight: root.clientHeight,
|
|
298
|
+
clipping: clipping,
|
|
299
|
+
};
|
|
300
|
+
`));
|
|
301
|
+
if (shotsDir) {
|
|
302
|
+
const safe = t.label.replace(/[^a-z0-9-]+/gi, '-').toLowerCase();
|
|
303
|
+
await page.screenshot({ path: join(shotsDir, `${safe}.png`) });
|
|
304
|
+
}
|
|
305
|
+
await page.close();
|
|
306
|
+
// Use a 1px tolerance — sub-pixel rounding and CSS-zoom quirks can
|
|
307
|
+
// make scrollWidth = clientWidth + 1 even when nothing visibly
|
|
308
|
+
// overflows.
|
|
309
|
+
const TOLERANCE = 1;
|
|
310
|
+
return {
|
|
311
|
+
label: t.label,
|
|
312
|
+
width: t.width,
|
|
313
|
+
height: t.height,
|
|
314
|
+
scrollWidth: dim.scrollWidth,
|
|
315
|
+
scrollHeight: dim.scrollHeight,
|
|
316
|
+
scrollsX: dim.scrollWidth > dim.clientWidth + TOLERANCE,
|
|
317
|
+
scrollsY: dim.scrollHeight > dim.clientHeight + TOLERANCE,
|
|
318
|
+
clippingElements: dim.clipping,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function renderCoverage(cov, matrix) {
|
|
322
|
+
const isTTY = Boolean(process.stdout.isTTY) && process.env['NO_COLOR'] !== '1';
|
|
323
|
+
const c = (open) => (s) => (isTTY ? `\x1b[${open}m${s}\x1b[39m` : s);
|
|
324
|
+
const ok = c('32');
|
|
325
|
+
const warn = c('33');
|
|
326
|
+
const bad = c('31');
|
|
327
|
+
const colorize = (pct) => pct >= 95 ? ok : pct >= 80 ? warn : bad;
|
|
328
|
+
const hasPortrait = matrix.some((t) => t.orientation === 'portrait');
|
|
329
|
+
const hasLandscape = matrix.some((t) => t.orientation === 'landscape');
|
|
330
|
+
process.stdout.write('Device coverage:\n');
|
|
331
|
+
if (hasPortrait) {
|
|
332
|
+
process.stdout.write(` portrait: ${colorize(cov.portrait)(`~${cov.portrait}%`)} of devices\n`);
|
|
333
|
+
}
|
|
334
|
+
if (hasLandscape) {
|
|
335
|
+
process.stdout.write(` landscape: ${colorize(cov.landscape)(`~${cov.landscape}%`)} of devices\n`);
|
|
336
|
+
}
|
|
337
|
+
if (hasPortrait && hasLandscape) {
|
|
338
|
+
process.stdout.write(` overall: ${colorize(cov.overall)(`~${cov.overall}%`)} (worst-case across orientations)\n`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function renderResult(r) {
|
|
342
|
+
const isTTY = Boolean(process.stdout.isTTY) && process.env['NO_COLOR'] !== '1';
|
|
343
|
+
const c = (open) => (s) => (isTTY ? `\x1b[${open}m${s}\x1b[39m` : s);
|
|
344
|
+
const ok = c('32');
|
|
345
|
+
const bad = c('31');
|
|
346
|
+
const dim = (s) => (isTTY ? `\x1b[2m${s}\x1b[22m` : s);
|
|
347
|
+
const hasIssue = r.scrollsX || r.scrollsY || r.clippingElements.length > 0;
|
|
348
|
+
if (!hasIssue) {
|
|
349
|
+
process.stdout.write(` ${ok('✓')} ${r.label.padEnd(40)} ${dim(`fits cleanly`)}\n`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const issues = [];
|
|
353
|
+
if (r.scrollsX)
|
|
354
|
+
issues.push(`scrolls horizontally (${r.scrollWidth}px > ${r.width}px)`);
|
|
355
|
+
if (r.scrollsY)
|
|
356
|
+
issues.push(`scrolls vertically (${r.scrollHeight}px > ${r.height}px)`);
|
|
357
|
+
if (r.clippingElements.length > 0) {
|
|
358
|
+
issues.push(`${r.clippingElements.length} element(s) clip content`);
|
|
359
|
+
}
|
|
360
|
+
process.stdout.write(` ${bad('✗')} ${r.label.padEnd(40)} ${dim(issues.join(' · '))}\n`);
|
|
361
|
+
// Show the worst clipping offenders. Cap at 3 per viewport to keep
|
|
362
|
+
// output readable; the screenshot is the receipt for the rest.
|
|
363
|
+
for (const el of r.clippingElements.slice(0, 3)) {
|
|
364
|
+
const sel = el.id ? `#${el.id}` : el.cls ? `.${el.cls.split(/\s+/)[0]}` : '';
|
|
365
|
+
const detail = [];
|
|
366
|
+
if (el.clipsX)
|
|
367
|
+
detail.push(`x:${el.scrollW}>${el.clientW}`);
|
|
368
|
+
if (el.clipsY)
|
|
369
|
+
detail.push(`y:${el.scrollH}>${el.clientH}`);
|
|
370
|
+
process.stdout.write(` ${dim(`<${el.tag}${sel}>`)} ${dim(detail.join(' '))}\n`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async function readManifest(dir) {
|
|
374
|
+
try {
|
|
375
|
+
const raw = await readFile(join(dir, 'web', 'public', 'manifest.json'), 'utf8');
|
|
376
|
+
return JSON.parse(raw);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async function loadPlaywright(targetDir) {
|
|
383
|
+
// playwright is an OPTIONAL peer dep installed in the user's project,
|
|
384
|
+
// not in the CLI's own node_modules. Resolve from the target dir so
|
|
385
|
+
// the user can `pnpm add -D playwright` in their app and have it Just
|
|
386
|
+
// Work, rather than needing it co-located with a globally-installed CLI.
|
|
387
|
+
try {
|
|
388
|
+
const require = createRequire(join(targetDir, 'package.json'));
|
|
389
|
+
const resolved = require.resolve('playwright');
|
|
390
|
+
const mod = (await import(pathToFileURL(resolved).href));
|
|
391
|
+
return mod.chromium ?? mod.default?.chromium ?? null;
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function runShell(cmd, args, cwd) {
|
|
398
|
+
return new Promise((res, rej) => {
|
|
399
|
+
const child = spawn(cmd, args, { cwd, stdio: 'inherit' });
|
|
400
|
+
child.on('exit', (code) => (code === 0 ? res() : rej(new Error(`${cmd} exited ${code}`))));
|
|
401
|
+
child.on('error', rej);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
async function startServer(rootDir, port) {
|
|
405
|
+
const server = createServer(async (req, res) => {
|
|
406
|
+
const url = new URL(req.url ?? '/', 'http://x');
|
|
407
|
+
let p = decodeURIComponent(url.pathname);
|
|
408
|
+
if (p.endsWith('/'))
|
|
409
|
+
p += 'index.html';
|
|
410
|
+
const safe = normalize(p).replace(/^(\.\.[/\\])+/, '');
|
|
411
|
+
const filePath = join(rootDir, safe);
|
|
412
|
+
try {
|
|
413
|
+
const s = await stat(filePath);
|
|
414
|
+
if (s.isDirectory())
|
|
415
|
+
throw new Error('is dir');
|
|
416
|
+
const body = await readFile(filePath);
|
|
417
|
+
res.writeHead(200, { 'Content-Type': MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream' });
|
|
418
|
+
res.end(body);
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
res.writeHead(404);
|
|
422
|
+
res.end('not found');
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve));
|
|
426
|
+
return { close: () => server.close() };
|
|
427
|
+
}
|
|
428
|
+
//# sourceMappingURL=screencheck.js.map
|