@get-skipper/core 1.0.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/LICENSE +21 -0
- package/README.md +45 -0
- package/dist/index.d.mts +183 -0
- package/dist/index.d.ts +183 -0
- package/dist/index.js +348 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +304 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Skipper Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @get-skipper/core
|
|
2
|
+
|
|
3
|
+
Core package for Skipper — Google Sheets client, resolver, and shared utilities used by all framework plugins.
|
|
4
|
+
|
|
5
|
+
This package is not typically used directly. Install the plugin for your test framework instead:
|
|
6
|
+
- [`@get-skipper/playwright`](../playwright/README.md)
|
|
7
|
+
- [`@get-skipper/jest`](../jest/README.md)
|
|
8
|
+
- [`@get-skipper/vitest`](../vitest/README.md)
|
|
9
|
+
- [`@get-skipper/cypress`](../cypress/README.md)
|
|
10
|
+
- [`@get-skipper/nightwatch`](../nightwatch/README.md)
|
|
11
|
+
|
|
12
|
+
## API
|
|
13
|
+
|
|
14
|
+
### `SkipperResolver`
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { SkipperResolver } from '@get-skipper/core';
|
|
18
|
+
|
|
19
|
+
const resolver = new SkipperResolver({
|
|
20
|
+
spreadsheetId: 'your-spreadsheet-id',
|
|
21
|
+
credentials: { credentialsFile: './service-account.json' },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await resolver.initialize();
|
|
25
|
+
resolver.isTestEnabled('tests/auth/login.spec.ts > login > should log in'); // true | false
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### `buildTestId(filePath, titlePath)`
|
|
29
|
+
|
|
30
|
+
Builds a canonical test ID from a file path and title path array:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { buildTestId } from '@get-skipper/core';
|
|
34
|
+
|
|
35
|
+
buildTestId('/abs/path/tests/auth/login.spec.ts', ['login', 'should log in']);
|
|
36
|
+
// → "tests/auth/login.spec.ts > login > should log in"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### `SheetsWriter`
|
|
40
|
+
|
|
41
|
+
Used internally by plugins in sync mode to reconcile the spreadsheet.
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { sheets_v4 } from 'googleapis';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Skipper logger — output is suppressed unless SKIPPER_DEBUG=1 (or any truthy value).
|
|
5
|
+
*/
|
|
6
|
+
declare function log(message: string): void;
|
|
7
|
+
declare function warn(message: string): void;
|
|
8
|
+
declare function error(message: string): void;
|
|
9
|
+
|
|
10
|
+
type SkipperMode = 'read-only' | 'sync';
|
|
11
|
+
interface ServiceAccountCredentials {
|
|
12
|
+
type: 'service_account';
|
|
13
|
+
project_id: string;
|
|
14
|
+
private_key_id: string;
|
|
15
|
+
private_key: string;
|
|
16
|
+
client_email: string;
|
|
17
|
+
client_id: string;
|
|
18
|
+
auth_uri: string;
|
|
19
|
+
token_uri: string;
|
|
20
|
+
}
|
|
21
|
+
type SkipperCredentials = ServiceAccountCredentials | {
|
|
22
|
+
credentialsFile: string;
|
|
23
|
+
} | {
|
|
24
|
+
credentialsBase64: string;
|
|
25
|
+
};
|
|
26
|
+
interface SkipperConfig {
|
|
27
|
+
/** Google Spreadsheet ID (from the URL). */
|
|
28
|
+
spreadsheetId: string;
|
|
29
|
+
/**
|
|
30
|
+
* Service account credentials. Three forms accepted:
|
|
31
|
+
* - Inline object: the parsed JSON service account
|
|
32
|
+
* - `{ credentialsFile: './service-account.json' }` — path to JSON file (local dev)
|
|
33
|
+
* - `{ credentialsBase64: process.env.GOOGLE_CREDS_B64 }` — base64-encoded JSON (CI)
|
|
34
|
+
*/
|
|
35
|
+
credentials: SkipperCredentials;
|
|
36
|
+
/** Sheet tab name. Defaults to the first sheet. */
|
|
37
|
+
sheetName?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Additional sheet tab names to read from (read-only).
|
|
40
|
+
* Entries from these sheets are merged with the primary sheet.
|
|
41
|
+
* Useful for shared skip lists across multiple projects.
|
|
42
|
+
* When the same test ID appears in multiple sheets, the most
|
|
43
|
+
* restrictive (latest) disabledUntil date wins.
|
|
44
|
+
*/
|
|
45
|
+
referenceSheets?: string[];
|
|
46
|
+
/** Header name of the test ID column. Defaults to "testId". */
|
|
47
|
+
testIdColumn?: string;
|
|
48
|
+
/** Header name of the disabledUntil date column. Defaults to "disabledUntil". */
|
|
49
|
+
disabledUntilColumn?: string;
|
|
50
|
+
}
|
|
51
|
+
interface TestEntry {
|
|
52
|
+
testId: string;
|
|
53
|
+
/** null = no date set = test is enabled */
|
|
54
|
+
disabledUntil: Date | null;
|
|
55
|
+
notes?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface SheetFetchResult {
|
|
59
|
+
/** Resolved sheet tab name. */
|
|
60
|
+
sheetName: string;
|
|
61
|
+
/** Numeric sheet ID (used for batchUpdate deletions). */
|
|
62
|
+
sheetId: number;
|
|
63
|
+
/** Raw rows including the header row (row 0). */
|
|
64
|
+
rawRows: string[][];
|
|
65
|
+
/** Parsed header cells (trimmed). */
|
|
66
|
+
header: string[];
|
|
67
|
+
/** Parsed test entries. */
|
|
68
|
+
entries: TestEntry[];
|
|
69
|
+
}
|
|
70
|
+
interface FetchAllResult {
|
|
71
|
+
/** Full data for the primary (writable) sheet — used by SheetsWriter. */
|
|
72
|
+
primary: SheetFetchResult;
|
|
73
|
+
/** Merged entries from primary + all referenceSheets — used by SkipperResolver. */
|
|
74
|
+
entries: TestEntry[];
|
|
75
|
+
/**
|
|
76
|
+
* Authenticated Sheets API client — returned here so callers (SheetsWriter)
|
|
77
|
+
* can reuse the same auth session for write operations without a second auth call.
|
|
78
|
+
*/
|
|
79
|
+
sheets: sheets_v4.Sheets;
|
|
80
|
+
}
|
|
81
|
+
declare class SheetsClient {
|
|
82
|
+
private readonly config;
|
|
83
|
+
constructor(config: SkipperConfig);
|
|
84
|
+
private fetchSheet;
|
|
85
|
+
/**
|
|
86
|
+
* Fetches the primary sheet and all reference sheets in a single API session.
|
|
87
|
+
*
|
|
88
|
+
* Returns:
|
|
89
|
+
* - `primary`: the primary sheet's full result (rawRows + header) for writer use
|
|
90
|
+
* - `entries`: merged test entries from all sheets (for resolver use)
|
|
91
|
+
* - `sheets`: the authenticated Sheets API client (reuse for write operations)
|
|
92
|
+
*
|
|
93
|
+
* googleapis and google-auth-library are loaded here via dynamic import so that
|
|
94
|
+
* worker processes, which only call SkipperResolver.fromJSON(), never load them.
|
|
95
|
+
*
|
|
96
|
+
* Deduplication: when the same testId appears in multiple sheets, the most
|
|
97
|
+
* restrictive (latest) disabledUntil wins.
|
|
98
|
+
*/
|
|
99
|
+
fetchAll(): Promise<FetchAllResult>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
declare class SheetsWriter {
|
|
103
|
+
private readonly client;
|
|
104
|
+
private readonly config;
|
|
105
|
+
constructor(config: SkipperConfig);
|
|
106
|
+
/**
|
|
107
|
+
* Reconciles the spreadsheet with the discovered test IDs:
|
|
108
|
+
* - Appends rows for tests not yet in the primary sheet (with empty disabledUntil)
|
|
109
|
+
* - Deletes rows for tests that no longer exist in the suite
|
|
110
|
+
*
|
|
111
|
+
* Only the primary sheet is modified. Reference sheets are never written to.
|
|
112
|
+
* Rows are matched by normalized testId (case-insensitive, whitespace-collapsed).
|
|
113
|
+
* The header row (row 1) is never modified.
|
|
114
|
+
*
|
|
115
|
+
* A single fetchAll() call is made to retrieve sheet metadata, existing entries,
|
|
116
|
+
* and raw rows — no redundant API calls.
|
|
117
|
+
*/
|
|
118
|
+
sync(discoveredIds: string[]): Promise<void>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* SkipperResolver is the primary interface used by framework plugins.
|
|
123
|
+
*
|
|
124
|
+
* Lifecycle:
|
|
125
|
+
* 1. Call `initialize()` once before tests run (in globalSetup / before hook).
|
|
126
|
+
* 2. Call `isTestEnabled(testId)` per test to decide whether to skip.
|
|
127
|
+
* 3. In sync mode, call `toJSON()` to serialize the cache for cross-process sharing.
|
|
128
|
+
* 4. In worker processes, use `SkipperResolver.fromJSON()` to rehydrate.
|
|
129
|
+
*/
|
|
130
|
+
declare class SkipperResolver {
|
|
131
|
+
private readonly client;
|
|
132
|
+
private readonly config;
|
|
133
|
+
/** normalized testId → disabledUntil ISO string (null = no date = enabled) */
|
|
134
|
+
private cache;
|
|
135
|
+
private initialized;
|
|
136
|
+
constructor(config: SkipperConfig);
|
|
137
|
+
/**
|
|
138
|
+
* Fetches the spreadsheet and populates the in-memory cache.
|
|
139
|
+
* Must be called once before `isTestEnabled()`.
|
|
140
|
+
*/
|
|
141
|
+
initialize(): Promise<void>;
|
|
142
|
+
/**
|
|
143
|
+
* Returns true if the test should run.
|
|
144
|
+
*
|
|
145
|
+
* Logic:
|
|
146
|
+
* - Not in spreadsheet → true (opt-out model: unknown tests run by default)
|
|
147
|
+
* - disabledUntil is null or in the past → true
|
|
148
|
+
* - disabledUntil is in the future → false
|
|
149
|
+
*/
|
|
150
|
+
isTestEnabled(testId: string): boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Serializes the cache for cross-process sharing (e.g. globalSetup → workers).
|
|
153
|
+
* Dates are stored as ISO strings; null means no date (enabled).
|
|
154
|
+
*/
|
|
155
|
+
toJSON(): Record<string, string | null>;
|
|
156
|
+
/**
|
|
157
|
+
* Rehydrates a resolver from a serialized cache.
|
|
158
|
+
* Used in worker processes that cannot call initialize() again.
|
|
159
|
+
*/
|
|
160
|
+
static fromJSON(data: Record<string, string | null>): SkipperResolver;
|
|
161
|
+
getMode(): SkipperMode;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Normalizes a testId for consistent comparison:
|
|
166
|
+
* - trim leading/trailing whitespace
|
|
167
|
+
* - lowercase
|
|
168
|
+
* - collapse multiple whitespace characters into a single space
|
|
169
|
+
*/
|
|
170
|
+
declare function normalizeTestId(id: string): string;
|
|
171
|
+
/**
|
|
172
|
+
* Builds a canonical testId from a file path and the test title path.
|
|
173
|
+
*
|
|
174
|
+
* Format: "{relativePath} > {titlePath.join(' > ')}"
|
|
175
|
+
* Example: "tests/auth/login.spec.ts > login > should log in with valid credentials"
|
|
176
|
+
*
|
|
177
|
+
* The filePath is made relative to process.cwd() if it is absolute.
|
|
178
|
+
* The titlePath is the array of describe block names + the test name,
|
|
179
|
+
* as provided by the test framework (never pre-joined).
|
|
180
|
+
*/
|
|
181
|
+
declare function buildTestId(filePath: string, titlePath: string[]): string;
|
|
182
|
+
|
|
183
|
+
export { type ServiceAccountCredentials, SheetsClient, SheetsWriter, type SkipperConfig, type SkipperCredentials, type SkipperMode, SkipperResolver, type TestEntry, buildTestId, error, log, normalizeTestId, warn };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { sheets_v4 } from 'googleapis';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Skipper logger — output is suppressed unless SKIPPER_DEBUG=1 (or any truthy value).
|
|
5
|
+
*/
|
|
6
|
+
declare function log(message: string): void;
|
|
7
|
+
declare function warn(message: string): void;
|
|
8
|
+
declare function error(message: string): void;
|
|
9
|
+
|
|
10
|
+
type SkipperMode = 'read-only' | 'sync';
|
|
11
|
+
interface ServiceAccountCredentials {
|
|
12
|
+
type: 'service_account';
|
|
13
|
+
project_id: string;
|
|
14
|
+
private_key_id: string;
|
|
15
|
+
private_key: string;
|
|
16
|
+
client_email: string;
|
|
17
|
+
client_id: string;
|
|
18
|
+
auth_uri: string;
|
|
19
|
+
token_uri: string;
|
|
20
|
+
}
|
|
21
|
+
type SkipperCredentials = ServiceAccountCredentials | {
|
|
22
|
+
credentialsFile: string;
|
|
23
|
+
} | {
|
|
24
|
+
credentialsBase64: string;
|
|
25
|
+
};
|
|
26
|
+
interface SkipperConfig {
|
|
27
|
+
/** Google Spreadsheet ID (from the URL). */
|
|
28
|
+
spreadsheetId: string;
|
|
29
|
+
/**
|
|
30
|
+
* Service account credentials. Three forms accepted:
|
|
31
|
+
* - Inline object: the parsed JSON service account
|
|
32
|
+
* - `{ credentialsFile: './service-account.json' }` — path to JSON file (local dev)
|
|
33
|
+
* - `{ credentialsBase64: process.env.GOOGLE_CREDS_B64 }` — base64-encoded JSON (CI)
|
|
34
|
+
*/
|
|
35
|
+
credentials: SkipperCredentials;
|
|
36
|
+
/** Sheet tab name. Defaults to the first sheet. */
|
|
37
|
+
sheetName?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Additional sheet tab names to read from (read-only).
|
|
40
|
+
* Entries from these sheets are merged with the primary sheet.
|
|
41
|
+
* Useful for shared skip lists across multiple projects.
|
|
42
|
+
* When the same test ID appears in multiple sheets, the most
|
|
43
|
+
* restrictive (latest) disabledUntil date wins.
|
|
44
|
+
*/
|
|
45
|
+
referenceSheets?: string[];
|
|
46
|
+
/** Header name of the test ID column. Defaults to "testId". */
|
|
47
|
+
testIdColumn?: string;
|
|
48
|
+
/** Header name of the disabledUntil date column. Defaults to "disabledUntil". */
|
|
49
|
+
disabledUntilColumn?: string;
|
|
50
|
+
}
|
|
51
|
+
interface TestEntry {
|
|
52
|
+
testId: string;
|
|
53
|
+
/** null = no date set = test is enabled */
|
|
54
|
+
disabledUntil: Date | null;
|
|
55
|
+
notes?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface SheetFetchResult {
|
|
59
|
+
/** Resolved sheet tab name. */
|
|
60
|
+
sheetName: string;
|
|
61
|
+
/** Numeric sheet ID (used for batchUpdate deletions). */
|
|
62
|
+
sheetId: number;
|
|
63
|
+
/** Raw rows including the header row (row 0). */
|
|
64
|
+
rawRows: string[][];
|
|
65
|
+
/** Parsed header cells (trimmed). */
|
|
66
|
+
header: string[];
|
|
67
|
+
/** Parsed test entries. */
|
|
68
|
+
entries: TestEntry[];
|
|
69
|
+
}
|
|
70
|
+
interface FetchAllResult {
|
|
71
|
+
/** Full data for the primary (writable) sheet — used by SheetsWriter. */
|
|
72
|
+
primary: SheetFetchResult;
|
|
73
|
+
/** Merged entries from primary + all referenceSheets — used by SkipperResolver. */
|
|
74
|
+
entries: TestEntry[];
|
|
75
|
+
/**
|
|
76
|
+
* Authenticated Sheets API client — returned here so callers (SheetsWriter)
|
|
77
|
+
* can reuse the same auth session for write operations without a second auth call.
|
|
78
|
+
*/
|
|
79
|
+
sheets: sheets_v4.Sheets;
|
|
80
|
+
}
|
|
81
|
+
declare class SheetsClient {
|
|
82
|
+
private readonly config;
|
|
83
|
+
constructor(config: SkipperConfig);
|
|
84
|
+
private fetchSheet;
|
|
85
|
+
/**
|
|
86
|
+
* Fetches the primary sheet and all reference sheets in a single API session.
|
|
87
|
+
*
|
|
88
|
+
* Returns:
|
|
89
|
+
* - `primary`: the primary sheet's full result (rawRows + header) for writer use
|
|
90
|
+
* - `entries`: merged test entries from all sheets (for resolver use)
|
|
91
|
+
* - `sheets`: the authenticated Sheets API client (reuse for write operations)
|
|
92
|
+
*
|
|
93
|
+
* googleapis and google-auth-library are loaded here via dynamic import so that
|
|
94
|
+
* worker processes, which only call SkipperResolver.fromJSON(), never load them.
|
|
95
|
+
*
|
|
96
|
+
* Deduplication: when the same testId appears in multiple sheets, the most
|
|
97
|
+
* restrictive (latest) disabledUntil wins.
|
|
98
|
+
*/
|
|
99
|
+
fetchAll(): Promise<FetchAllResult>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
declare class SheetsWriter {
|
|
103
|
+
private readonly client;
|
|
104
|
+
private readonly config;
|
|
105
|
+
constructor(config: SkipperConfig);
|
|
106
|
+
/**
|
|
107
|
+
* Reconciles the spreadsheet with the discovered test IDs:
|
|
108
|
+
* - Appends rows for tests not yet in the primary sheet (with empty disabledUntil)
|
|
109
|
+
* - Deletes rows for tests that no longer exist in the suite
|
|
110
|
+
*
|
|
111
|
+
* Only the primary sheet is modified. Reference sheets are never written to.
|
|
112
|
+
* Rows are matched by normalized testId (case-insensitive, whitespace-collapsed).
|
|
113
|
+
* The header row (row 1) is never modified.
|
|
114
|
+
*
|
|
115
|
+
* A single fetchAll() call is made to retrieve sheet metadata, existing entries,
|
|
116
|
+
* and raw rows — no redundant API calls.
|
|
117
|
+
*/
|
|
118
|
+
sync(discoveredIds: string[]): Promise<void>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* SkipperResolver is the primary interface used by framework plugins.
|
|
123
|
+
*
|
|
124
|
+
* Lifecycle:
|
|
125
|
+
* 1. Call `initialize()` once before tests run (in globalSetup / before hook).
|
|
126
|
+
* 2. Call `isTestEnabled(testId)` per test to decide whether to skip.
|
|
127
|
+
* 3. In sync mode, call `toJSON()` to serialize the cache for cross-process sharing.
|
|
128
|
+
* 4. In worker processes, use `SkipperResolver.fromJSON()` to rehydrate.
|
|
129
|
+
*/
|
|
130
|
+
declare class SkipperResolver {
|
|
131
|
+
private readonly client;
|
|
132
|
+
private readonly config;
|
|
133
|
+
/** normalized testId → disabledUntil ISO string (null = no date = enabled) */
|
|
134
|
+
private cache;
|
|
135
|
+
private initialized;
|
|
136
|
+
constructor(config: SkipperConfig);
|
|
137
|
+
/**
|
|
138
|
+
* Fetches the spreadsheet and populates the in-memory cache.
|
|
139
|
+
* Must be called once before `isTestEnabled()`.
|
|
140
|
+
*/
|
|
141
|
+
initialize(): Promise<void>;
|
|
142
|
+
/**
|
|
143
|
+
* Returns true if the test should run.
|
|
144
|
+
*
|
|
145
|
+
* Logic:
|
|
146
|
+
* - Not in spreadsheet → true (opt-out model: unknown tests run by default)
|
|
147
|
+
* - disabledUntil is null or in the past → true
|
|
148
|
+
* - disabledUntil is in the future → false
|
|
149
|
+
*/
|
|
150
|
+
isTestEnabled(testId: string): boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Serializes the cache for cross-process sharing (e.g. globalSetup → workers).
|
|
153
|
+
* Dates are stored as ISO strings; null means no date (enabled).
|
|
154
|
+
*/
|
|
155
|
+
toJSON(): Record<string, string | null>;
|
|
156
|
+
/**
|
|
157
|
+
* Rehydrates a resolver from a serialized cache.
|
|
158
|
+
* Used in worker processes that cannot call initialize() again.
|
|
159
|
+
*/
|
|
160
|
+
static fromJSON(data: Record<string, string | null>): SkipperResolver;
|
|
161
|
+
getMode(): SkipperMode;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Normalizes a testId for consistent comparison:
|
|
166
|
+
* - trim leading/trailing whitespace
|
|
167
|
+
* - lowercase
|
|
168
|
+
* - collapse multiple whitespace characters into a single space
|
|
169
|
+
*/
|
|
170
|
+
declare function normalizeTestId(id: string): string;
|
|
171
|
+
/**
|
|
172
|
+
* Builds a canonical testId from a file path and the test title path.
|
|
173
|
+
*
|
|
174
|
+
* Format: "{relativePath} > {titlePath.join(' > ')}"
|
|
175
|
+
* Example: "tests/auth/login.spec.ts > login > should log in with valid credentials"
|
|
176
|
+
*
|
|
177
|
+
* The filePath is made relative to process.cwd() if it is absolute.
|
|
178
|
+
* The titlePath is the array of describe block names + the test name,
|
|
179
|
+
* as provided by the test framework (never pre-joined).
|
|
180
|
+
*/
|
|
181
|
+
declare function buildTestId(filePath: string, titlePath: string[]): string;
|
|
182
|
+
|
|
183
|
+
export { type ServiceAccountCredentials, SheetsClient, SheetsWriter, type SkipperConfig, type SkipperCredentials, type SkipperMode, SkipperResolver, type TestEntry, buildTestId, error, log, normalizeTestId, warn };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
SheetsClient: () => SheetsClient,
|
|
34
|
+
SheetsWriter: () => SheetsWriter,
|
|
35
|
+
SkipperResolver: () => SkipperResolver,
|
|
36
|
+
buildTestId: () => buildTestId,
|
|
37
|
+
error: () => error,
|
|
38
|
+
log: () => log,
|
|
39
|
+
normalizeTestId: () => normalizeTestId,
|
|
40
|
+
warn: () => warn
|
|
41
|
+
});
|
|
42
|
+
module.exports = __toCommonJS(index_exports);
|
|
43
|
+
|
|
44
|
+
// src/logger.ts
|
|
45
|
+
function isEnabled() {
|
|
46
|
+
return Boolean(process.env.SKIPPER_DEBUG);
|
|
47
|
+
}
|
|
48
|
+
function log(message) {
|
|
49
|
+
if (isEnabled()) console.log(message);
|
|
50
|
+
}
|
|
51
|
+
function warn(message) {
|
|
52
|
+
if (isEnabled()) console.warn(message);
|
|
53
|
+
}
|
|
54
|
+
function error(message) {
|
|
55
|
+
if (isEnabled()) console.error(message);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/client.ts
|
|
59
|
+
var fs = __toESM(require("fs"));
|
|
60
|
+
|
|
61
|
+
// src/cache.ts
|
|
62
|
+
var path = __toESM(require("path"));
|
|
63
|
+
function normalizeTestId(id) {
|
|
64
|
+
return id.trim().toLowerCase().replace(/\s+/g, " ");
|
|
65
|
+
}
|
|
66
|
+
function buildTestId(filePath, titlePath) {
|
|
67
|
+
const relativePath = path.isAbsolute(filePath) ? path.relative(process.cwd(), filePath) : filePath;
|
|
68
|
+
const normalizedPath = relativePath.split(path.sep).join("/");
|
|
69
|
+
return [normalizedPath, ...titlePath].join(" > ");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/client.ts
|
|
73
|
+
function resolveCredentials(config) {
|
|
74
|
+
const { credentials } = config;
|
|
75
|
+
if ("credentialsFile" in credentials) {
|
|
76
|
+
const raw = fs.readFileSync(credentials.credentialsFile, "utf8");
|
|
77
|
+
return JSON.parse(raw);
|
|
78
|
+
}
|
|
79
|
+
if ("credentialsBase64" in credentials) {
|
|
80
|
+
const raw = Buffer.from(credentials.credentialsBase64, "base64").toString("utf8");
|
|
81
|
+
return JSON.parse(raw);
|
|
82
|
+
}
|
|
83
|
+
return credentials;
|
|
84
|
+
}
|
|
85
|
+
var SheetsClient = class {
|
|
86
|
+
constructor(config) {
|
|
87
|
+
this.config = config;
|
|
88
|
+
}
|
|
89
|
+
async fetchSheet(sheets, sheetName, sheetId) {
|
|
90
|
+
const spreadsheetId = this.config.spreadsheetId;
|
|
91
|
+
const testIdCol = this.config.testIdColumn ?? "testId";
|
|
92
|
+
const disabledUntilCol = this.config.disabledUntilColumn ?? "disabledUntil";
|
|
93
|
+
const response = await sheets.spreadsheets.values.get({ spreadsheetId, range: sheetName });
|
|
94
|
+
const rawRows = response.data.values ?? [];
|
|
95
|
+
if (rawRows.length === 0) {
|
|
96
|
+
return { sheetName, sheetId, rawRows, header: [], entries: [] };
|
|
97
|
+
}
|
|
98
|
+
const header = rawRows[0].map((h) => String(h).trim());
|
|
99
|
+
const testIdIdx = header.indexOf(testIdCol);
|
|
100
|
+
const disabledUntilIdx = header.indexOf(disabledUntilCol);
|
|
101
|
+
const notesIdx = header.indexOf("notes");
|
|
102
|
+
if (testIdIdx === -1) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`[skipper] Column "${testIdCol}" not found in sheet "${sheetName}". Found columns: ${header.join(", ")}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const entries = [];
|
|
108
|
+
for (let i = 1; i < rawRows.length; i++) {
|
|
109
|
+
const row = rawRows[i];
|
|
110
|
+
const testId = row[testIdIdx] ? String(row[testIdIdx]).trim() : "";
|
|
111
|
+
if (!testId) continue;
|
|
112
|
+
let disabledUntil = null;
|
|
113
|
+
if (disabledUntilIdx !== -1 && row[disabledUntilIdx]) {
|
|
114
|
+
const raw = String(row[disabledUntilIdx]).trim();
|
|
115
|
+
if (raw) {
|
|
116
|
+
const parsed = new Date(raw);
|
|
117
|
+
if (!isNaN(parsed.getTime())) {
|
|
118
|
+
disabledUntil = parsed;
|
|
119
|
+
} else {
|
|
120
|
+
warn(
|
|
121
|
+
`[skipper] Row ${i + 1} in "${sheetName}": invalid date "${raw}" in "${disabledUntilCol}" \u2014 treating as enabled`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const notes = notesIdx !== -1 && row[notesIdx] ? String(row[notesIdx]) : void 0;
|
|
127
|
+
entries.push({ testId, disabledUntil, notes });
|
|
128
|
+
}
|
|
129
|
+
return { sheetName, sheetId, rawRows, header, entries };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Fetches the primary sheet and all reference sheets in a single API session.
|
|
133
|
+
*
|
|
134
|
+
* Returns:
|
|
135
|
+
* - `primary`: the primary sheet's full result (rawRows + header) for writer use
|
|
136
|
+
* - `entries`: merged test entries from all sheets (for resolver use)
|
|
137
|
+
* - `sheets`: the authenticated Sheets API client (reuse for write operations)
|
|
138
|
+
*
|
|
139
|
+
* googleapis and google-auth-library are loaded here via dynamic import so that
|
|
140
|
+
* worker processes, which only call SkipperResolver.fromJSON(), never load them.
|
|
141
|
+
*
|
|
142
|
+
* Deduplication: when the same testId appears in multiple sheets, the most
|
|
143
|
+
* restrictive (latest) disabledUntil wins.
|
|
144
|
+
*/
|
|
145
|
+
async fetchAll() {
|
|
146
|
+
const { google } = await import("googleapis");
|
|
147
|
+
const { JWT } = await import("google-auth-library");
|
|
148
|
+
const creds = resolveCredentials(this.config);
|
|
149
|
+
const auth = new JWT({
|
|
150
|
+
email: creds.client_email,
|
|
151
|
+
key: creds.private_key,
|
|
152
|
+
scopes: ["https://www.googleapis.com/auth/spreadsheets"]
|
|
153
|
+
});
|
|
154
|
+
const sheets = google.sheets({ version: "v4", auth });
|
|
155
|
+
const spreadsheetId = this.config.spreadsheetId;
|
|
156
|
+
const meta = await sheets.spreadsheets.get({ spreadsheetId });
|
|
157
|
+
const allSheetMeta = meta.data.sheets ?? [];
|
|
158
|
+
const sheetIdByName = new Map(
|
|
159
|
+
allSheetMeta.filter((s) => s.properties?.title != null && s.properties.sheetId != null).map((s) => [s.properties.title, s.properties.sheetId])
|
|
160
|
+
);
|
|
161
|
+
const primaryName = this.config.sheetName ?? allSheetMeta[0]?.properties?.title ?? "Sheet1";
|
|
162
|
+
const primaryId = sheetIdByName.get(primaryName);
|
|
163
|
+
if (primaryId == null) {
|
|
164
|
+
throw new Error(`[skipper] Sheet "${primaryName}" not found in spreadsheet.`);
|
|
165
|
+
}
|
|
166
|
+
const primary = await this.fetchSheet(sheets, primaryName, primaryId);
|
|
167
|
+
const referenceEntries = [];
|
|
168
|
+
for (const refName of this.config.referenceSheets ?? []) {
|
|
169
|
+
const refId = sheetIdByName.get(refName);
|
|
170
|
+
if (refId == null) {
|
|
171
|
+
warn(`[skipper] Reference sheet "${refName}" not found \u2014 skipping.`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const result = await this.fetchSheet(sheets, refName, refId);
|
|
175
|
+
referenceEntries.push(...result.entries);
|
|
176
|
+
}
|
|
177
|
+
const merged = /* @__PURE__ */ new Map();
|
|
178
|
+
for (const entry of [...primary.entries, ...referenceEntries]) {
|
|
179
|
+
const key = normalizeTestId(entry.testId);
|
|
180
|
+
const existing = merged.get(key);
|
|
181
|
+
if (!existing) {
|
|
182
|
+
merged.set(key, entry);
|
|
183
|
+
} else if (entry.disabledUntil !== null) {
|
|
184
|
+
if (existing.disabledUntil === null || entry.disabledUntil > existing.disabledUntil) {
|
|
185
|
+
merged.set(key, entry);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { primary, entries: [...merged.values()], sheets };
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// src/writer.ts
|
|
194
|
+
var SheetsWriter = class {
|
|
195
|
+
constructor(config) {
|
|
196
|
+
this.config = config;
|
|
197
|
+
this.client = new SheetsClient(config);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Reconciles the spreadsheet with the discovered test IDs:
|
|
201
|
+
* - Appends rows for tests not yet in the primary sheet (with empty disabledUntil)
|
|
202
|
+
* - Deletes rows for tests that no longer exist in the suite
|
|
203
|
+
*
|
|
204
|
+
* Only the primary sheet is modified. Reference sheets are never written to.
|
|
205
|
+
* Rows are matched by normalized testId (case-insensitive, whitespace-collapsed).
|
|
206
|
+
* The header row (row 1) is never modified.
|
|
207
|
+
*
|
|
208
|
+
* A single fetchAll() call is made to retrieve sheet metadata, existing entries,
|
|
209
|
+
* and raw rows — no redundant API calls.
|
|
210
|
+
*/
|
|
211
|
+
async sync(discoveredIds) {
|
|
212
|
+
const { primary, entries: existingEntries, sheets } = await this.client.fetchAll();
|
|
213
|
+
const { sheetName, sheetId, rawRows, header } = primary;
|
|
214
|
+
const testIdCol = this.config.testIdColumn ?? "testId";
|
|
215
|
+
const disabledUntilCol = this.config.disabledUntilColumn ?? "disabledUntil";
|
|
216
|
+
const normalizedDiscovered = new Set(discoveredIds.map(normalizeTestId));
|
|
217
|
+
const normalizedExisting = new Map(
|
|
218
|
+
existingEntries.map((e) => [normalizeTestId(e.testId), e])
|
|
219
|
+
);
|
|
220
|
+
const toAdd = discoveredIds.filter((id) => !normalizedExisting.has(normalizeTestId(id)));
|
|
221
|
+
const toRemoveNormalized = new Set(
|
|
222
|
+
[...normalizedExisting.keys()].filter((nid) => !normalizedDiscovered.has(nid))
|
|
223
|
+
);
|
|
224
|
+
if (toAdd.length === 0 && toRemoveNormalized.size === 0) {
|
|
225
|
+
log("[skipper] Spreadsheet is already up to date.");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const spreadsheetId = this.config.spreadsheetId;
|
|
229
|
+
const testIdIdx = header.indexOf(testIdCol);
|
|
230
|
+
const rowIndicesToDelete = [];
|
|
231
|
+
for (let i = 1; i < rawRows.length; i++) {
|
|
232
|
+
const id = rawRows[i][testIdIdx] ? String(rawRows[i][testIdIdx]).trim() : "";
|
|
233
|
+
if (id && toRemoveNormalized.has(normalizeTestId(id))) {
|
|
234
|
+
rowIndicesToDelete.push(i);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const deleteRequests = rowIndicesToDelete.sort((a, b) => b - a).map((rowIdx) => ({
|
|
238
|
+
deleteDimension: {
|
|
239
|
+
range: { sheetId, dimension: "ROWS", startIndex: rowIdx, endIndex: rowIdx + 1 }
|
|
240
|
+
}
|
|
241
|
+
}));
|
|
242
|
+
if (deleteRequests.length > 0) {
|
|
243
|
+
await sheets.spreadsheets.batchUpdate({
|
|
244
|
+
spreadsheetId,
|
|
245
|
+
requestBody: { requests: deleteRequests }
|
|
246
|
+
});
|
|
247
|
+
log(`[skipper] Removed ${deleteRequests.length} obsolete test(s) from spreadsheet.`);
|
|
248
|
+
}
|
|
249
|
+
if (toAdd.length > 0) {
|
|
250
|
+
const headerIdxDisabledUntil = header.indexOf(disabledUntilCol);
|
|
251
|
+
const newRows = toAdd.map((testId) => {
|
|
252
|
+
const row = new Array(Math.max(testIdIdx + 1, headerIdxDisabledUntil + 1)).fill("");
|
|
253
|
+
row[testIdIdx] = testId;
|
|
254
|
+
if (headerIdxDisabledUntil !== -1) row[headerIdxDisabledUntil] = "";
|
|
255
|
+
return row;
|
|
256
|
+
});
|
|
257
|
+
await sheets.spreadsheets.values.append({
|
|
258
|
+
spreadsheetId,
|
|
259
|
+
range: sheetName,
|
|
260
|
+
valueInputOption: "RAW",
|
|
261
|
+
requestBody: { values: newRows }
|
|
262
|
+
});
|
|
263
|
+
log(`[skipper] Added ${toAdd.length} new test(s) to spreadsheet.`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// src/resolver.ts
|
|
269
|
+
var SkipperResolver = class _SkipperResolver {
|
|
270
|
+
constructor(config) {
|
|
271
|
+
/** normalized testId → disabledUntil ISO string (null = no date = enabled) */
|
|
272
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
273
|
+
this.initialized = false;
|
|
274
|
+
this.config = config;
|
|
275
|
+
this.client = new SheetsClient(config);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Fetches the spreadsheet and populates the in-memory cache.
|
|
279
|
+
* Must be called once before `isTestEnabled()`.
|
|
280
|
+
*/
|
|
281
|
+
async initialize() {
|
|
282
|
+
const { entries } = await this.client.fetchAll();
|
|
283
|
+
this.cache = new Map(
|
|
284
|
+
entries.map((e) => [
|
|
285
|
+
normalizeTestId(e.testId),
|
|
286
|
+
e.disabledUntil ? e.disabledUntil.toISOString() : null
|
|
287
|
+
])
|
|
288
|
+
);
|
|
289
|
+
this.initialized = true;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Returns true if the test should run.
|
|
293
|
+
*
|
|
294
|
+
* Logic:
|
|
295
|
+
* - Not in spreadsheet → true (opt-out model: unknown tests run by default)
|
|
296
|
+
* - disabledUntil is null or in the past → true
|
|
297
|
+
* - disabledUntil is in the future → false
|
|
298
|
+
*/
|
|
299
|
+
isTestEnabled(testId) {
|
|
300
|
+
if (!this.initialized) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
"[skipper] SkipperResolver.initialize() must be called before isTestEnabled(). Did you forget to add the globalSetup to your config?"
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
const normalized = normalizeTestId(testId);
|
|
306
|
+
if (!this.cache.has(normalized)) return true;
|
|
307
|
+
const iso = this.cache.get(normalized);
|
|
308
|
+
if (!iso) return true;
|
|
309
|
+
return new Date(iso) <= /* @__PURE__ */ new Date();
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Serializes the cache for cross-process sharing (e.g. globalSetup → workers).
|
|
313
|
+
* Dates are stored as ISO strings; null means no date (enabled).
|
|
314
|
+
*/
|
|
315
|
+
toJSON() {
|
|
316
|
+
return Object.fromEntries(this.cache);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Rehydrates a resolver from a serialized cache.
|
|
320
|
+
* Used in worker processes that cannot call initialize() again.
|
|
321
|
+
*/
|
|
322
|
+
static fromJSON(data) {
|
|
323
|
+
const resolver = new _SkipperResolver({
|
|
324
|
+
spreadsheetId: "",
|
|
325
|
+
credentials: { credentialsBase64: "" }
|
|
326
|
+
});
|
|
327
|
+
resolver.cache = new Map(Object.entries(data));
|
|
328
|
+
resolver.initialized = true;
|
|
329
|
+
return resolver;
|
|
330
|
+
}
|
|
331
|
+
getMode() {
|
|
332
|
+
const mode = process.env.SKIPPER_MODE;
|
|
333
|
+
if (mode === "sync") return "sync";
|
|
334
|
+
return "read-only";
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
338
|
+
0 && (module.exports = {
|
|
339
|
+
SheetsClient,
|
|
340
|
+
SheetsWriter,
|
|
341
|
+
SkipperResolver,
|
|
342
|
+
buildTestId,
|
|
343
|
+
error,
|
|
344
|
+
log,
|
|
345
|
+
normalizeTestId,
|
|
346
|
+
warn
|
|
347
|
+
});
|
|
348
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/logger.ts","../src/client.ts","../src/cache.ts","../src/writer.ts","../src/resolver.ts"],"sourcesContent":["export { log, warn, error } from './logger';\nexport { SheetsClient } from './client';\nexport { SheetsWriter } from './writer';\nexport { SkipperResolver } from './resolver';\nexport { buildTestId, normalizeTestId } from './cache';\nexport type {\n SkipperConfig,\n SkipperCredentials,\n SkipperMode,\n ServiceAccountCredentials,\n TestEntry,\n} from './types';\n","/**\n * Skipper logger — output is suppressed unless SKIPPER_DEBUG=1 (or any truthy value).\n */\n\nfunction isEnabled(): boolean {\n return Boolean(process.env.SKIPPER_DEBUG);\n}\n\nexport function log(message: string): void {\n if (isEnabled()) console.log(message);\n}\n\nexport function warn(message: string): void {\n if (isEnabled()) console.warn(message);\n}\n\nexport function error(message: string): void {\n if (isEnabled()) console.error(message);\n}\n","import * as fs from 'fs';\nimport { normalizeTestId } from './cache';\nimport { warn } from './logger';\nimport type { SkipperConfig, TestEntry, ServiceAccountCredentials } from './types';\n\n// googleapis and google-auth-library are imported dynamically inside fetchAll() so that\n// worker processes that only call SkipperResolver.fromJSON() never load these modules.\n// Loading googleapis initialises an HTTP keep-alive agent that prevents worker exit.\nimport type { sheets_v4 } from 'googleapis';\n\nfunction resolveCredentials(config: SkipperConfig): ServiceAccountCredentials {\n const { credentials } = config;\n\n if ('credentialsFile' in credentials) {\n const raw = fs.readFileSync(credentials.credentialsFile, 'utf8');\n return JSON.parse(raw) as ServiceAccountCredentials;\n }\n\n if ('credentialsBase64' in credentials) {\n const raw = Buffer.from(credentials.credentialsBase64, 'base64').toString('utf8');\n return JSON.parse(raw) as ServiceAccountCredentials;\n }\n\n return credentials as ServiceAccountCredentials;\n}\n\nexport interface SheetFetchResult {\n /** Resolved sheet tab name. */\n sheetName: string;\n /** Numeric sheet ID (used for batchUpdate deletions). */\n sheetId: number;\n /** Raw rows including the header row (row 0). */\n rawRows: string[][];\n /** Parsed header cells (trimmed). */\n header: string[];\n /** Parsed test entries. */\n entries: TestEntry[];\n}\n\nexport interface FetchAllResult {\n /** Full data for the primary (writable) sheet — used by SheetsWriter. */\n primary: SheetFetchResult;\n /** Merged entries from primary + all referenceSheets — used by SkipperResolver. */\n entries: TestEntry[];\n /**\n * Authenticated Sheets API client — returned here so callers (SheetsWriter)\n * can reuse the same auth session for write operations without a second auth call.\n */\n sheets: sheets_v4.Sheets;\n}\n\nexport class SheetsClient {\n private readonly config: SkipperConfig;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n }\n\n private async fetchSheet(\n sheets: sheets_v4.Sheets,\n sheetName: string,\n sheetId: number,\n ): Promise<SheetFetchResult> {\n const spreadsheetId = this.config.spreadsheetId;\n const testIdCol = this.config.testIdColumn ?? 'testId';\n const disabledUntilCol = this.config.disabledUntilColumn ?? 'disabledUntil';\n\n const response = await sheets.spreadsheets.values.get({ spreadsheetId, range: sheetName });\n const rawRows = (response.data.values ?? []) as string[][];\n\n if (rawRows.length === 0) {\n return { sheetName, sheetId, rawRows, header: [], entries: [] };\n }\n\n const header = rawRows[0].map((h: string) => String(h).trim());\n const testIdIdx = header.indexOf(testIdCol);\n const disabledUntilIdx = header.indexOf(disabledUntilCol);\n const notesIdx = header.indexOf('notes');\n\n if (testIdIdx === -1) {\n throw new Error(\n `[skipper] Column \"${testIdCol}\" not found in sheet \"${sheetName}\". ` +\n `Found columns: ${header.join(', ')}`,\n );\n }\n\n const entries: TestEntry[] = [];\n for (let i = 1; i < rawRows.length; i++) {\n const row = rawRows[i];\n const testId = row[testIdIdx] ? String(row[testIdIdx]).trim() : '';\n if (!testId) continue;\n\n let disabledUntil: Date | null = null;\n if (disabledUntilIdx !== -1 && row[disabledUntilIdx]) {\n const raw = String(row[disabledUntilIdx]).trim();\n if (raw) {\n const parsed = new Date(raw);\n if (!isNaN(parsed.getTime())) {\n disabledUntil = parsed;\n } else {\n warn(\n `[skipper] Row ${i + 1} in \"${sheetName}\": invalid date \"${raw}\" in \"${disabledUntilCol}\" — treating as enabled`,\n );\n }\n }\n }\n\n const notes = notesIdx !== -1 && row[notesIdx] ? String(row[notesIdx]) : undefined;\n entries.push({ testId, disabledUntil, notes });\n }\n\n return { sheetName, sheetId, rawRows, header, entries };\n }\n\n /**\n * Fetches the primary sheet and all reference sheets in a single API session.\n *\n * Returns:\n * - `primary`: the primary sheet's full result (rawRows + header) for writer use\n * - `entries`: merged test entries from all sheets (for resolver use)\n * - `sheets`: the authenticated Sheets API client (reuse for write operations)\n *\n * googleapis and google-auth-library are loaded here via dynamic import so that\n * worker processes, which only call SkipperResolver.fromJSON(), never load them.\n *\n * Deduplication: when the same testId appears in multiple sheets, the most\n * restrictive (latest) disabledUntil wins.\n */\n async fetchAll(): Promise<FetchAllResult> {\n // Dynamic imports — only executed when actually fetching from the spreadsheet.\n const { google } = await import('googleapis');\n const { JWT } = await import('google-auth-library');\n\n const creds = resolveCredentials(this.config);\n const auth = new JWT({\n email: creds.client_email,\n key: creds.private_key,\n scopes: ['https://www.googleapis.com/auth/spreadsheets'],\n });\n const sheets = google.sheets({ version: 'v4', auth });\n\n const spreadsheetId = this.config.spreadsheetId;\n const meta = await sheets.spreadsheets.get({ spreadsheetId });\n const allSheetMeta = meta.data.sheets ?? [];\n\n const sheetIdByName = new Map<string, number>(\n allSheetMeta\n .filter((s) => s.properties?.title != null && s.properties.sheetId != null)\n .map((s) => [s.properties!.title!, s.properties!.sheetId!]),\n );\n\n const primaryName = this.config.sheetName ?? allSheetMeta[0]?.properties?.title ?? 'Sheet1';\n const primaryId = sheetIdByName.get(primaryName);\n if (primaryId == null) {\n throw new Error(`[skipper] Sheet \"${primaryName}\" not found in spreadsheet.`);\n }\n\n const primary = await this.fetchSheet(sheets, primaryName, primaryId);\n\n const referenceEntries: TestEntry[] = [];\n for (const refName of this.config.referenceSheets ?? []) {\n const refId = sheetIdByName.get(refName);\n if (refId == null) {\n warn(`[skipper] Reference sheet \"${refName}\" not found — skipping.`);\n continue;\n }\n const result = await this.fetchSheet(sheets, refName, refId);\n referenceEntries.push(...result.entries);\n }\n\n const merged = new Map<string, TestEntry>();\n for (const entry of [...primary.entries, ...referenceEntries]) {\n const key = normalizeTestId(entry.testId);\n const existing = merged.get(key);\n if (!existing) {\n merged.set(key, entry);\n } else if (entry.disabledUntil !== null) {\n if (existing.disabledUntil === null || entry.disabledUntil > existing.disabledUntil) {\n merged.set(key, entry);\n }\n }\n }\n\n return { primary, entries: [...merged.values()], sheets };\n }\n}\n","import * as path from 'path';\n\n/**\n * Normalizes a testId for consistent comparison:\n * - trim leading/trailing whitespace\n * - lowercase\n * - collapse multiple whitespace characters into a single space\n */\nexport function normalizeTestId(id: string): string {\n return id.trim().toLowerCase().replace(/\\s+/g, ' ');\n}\n\n/**\n * Builds a canonical testId from a file path and the test title path.\n *\n * Format: \"{relativePath} > {titlePath.join(' > ')}\"\n * Example: \"tests/auth/login.spec.ts > login > should log in with valid credentials\"\n *\n * The filePath is made relative to process.cwd() if it is absolute.\n * The titlePath is the array of describe block names + the test name,\n * as provided by the test framework (never pre-joined).\n */\nexport function buildTestId(filePath: string, titlePath: string[]): string {\n const relativePath = path.isAbsolute(filePath)\n ? path.relative(process.cwd(), filePath)\n : filePath;\n\n const normalizedPath = relativePath.split(path.sep).join('/');\n return [normalizedPath, ...titlePath].join(' > ');\n}\n","import { SheetsClient } from './client';\nimport { normalizeTestId } from './cache';\nimport { log } from './logger';\nimport type { SkipperConfig, TestEntry } from './types';\n\nexport class SheetsWriter {\n private readonly client: SheetsClient;\n private readonly config: SkipperConfig;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n this.client = new SheetsClient(config);\n }\n\n /**\n * Reconciles the spreadsheet with the discovered test IDs:\n * - Appends rows for tests not yet in the primary sheet (with empty disabledUntil)\n * - Deletes rows for tests that no longer exist in the suite\n *\n * Only the primary sheet is modified. Reference sheets are never written to.\n * Rows are matched by normalized testId (case-insensitive, whitespace-collapsed).\n * The header row (row 1) is never modified.\n *\n * A single fetchAll() call is made to retrieve sheet metadata, existing entries,\n * and raw rows — no redundant API calls.\n */\n async sync(discoveredIds: string[]): Promise<void> {\n // One fetchAll() resolves the sheet name from metadata, fetches existing\n // entries, and returns raw rows and the authenticated Sheets client —\n // all in two API calls (metadata + values). No second auth/fetch needed.\n const { primary, entries: existingEntries, sheets } = await this.client.fetchAll();\n const { sheetName, sheetId, rawRows, header } = primary;\n\n const testIdCol = this.config.testIdColumn ?? 'testId';\n const disabledUntilCol = this.config.disabledUntilColumn ?? 'disabledUntil';\n\n const normalizedDiscovered = new Set(discoveredIds.map(normalizeTestId));\n const normalizedExisting = new Map<string, TestEntry>(\n existingEntries.map((e) => [normalizeTestId(e.testId), e]),\n );\n\n const toAdd = discoveredIds.filter((id) => !normalizedExisting.has(normalizeTestId(id)));\n const toRemoveNormalized = new Set(\n [...normalizedExisting.keys()].filter((nid) => !normalizedDiscovered.has(nid)),\n );\n\n if (toAdd.length === 0 && toRemoveNormalized.size === 0) {\n log('[skipper] Spreadsheet is already up to date.');\n return;\n }\n\n const spreadsheetId = this.config.spreadsheetId;\n\n const testIdIdx = header.indexOf(testIdCol);\n\n // Identify 0-based row indices (within rawRows) to delete, skipping header at 0.\n const rowIndicesToDelete: number[] = [];\n for (let i = 1; i < rawRows.length; i++) {\n const id = rawRows[i][testIdIdx] ? String(rawRows[i][testIdIdx]).trim() : '';\n if (id && toRemoveNormalized.has(normalizeTestId(id))) {\n rowIndicesToDelete.push(i);\n }\n }\n\n // Deletions must be sorted descending to avoid index shifting.\n const deleteRequests = rowIndicesToDelete\n .sort((a, b) => b - a)\n .map((rowIdx) => ({\n deleteDimension: {\n range: { sheetId, dimension: 'ROWS', startIndex: rowIdx, endIndex: rowIdx + 1 },\n },\n }));\n\n if (deleteRequests.length > 0) {\n await sheets.spreadsheets.batchUpdate({\n spreadsheetId,\n requestBody: { requests: deleteRequests },\n });\n log(`[skipper] Removed ${deleteRequests.length} obsolete test(s) from spreadsheet.`);\n }\n\n // Append new rows.\n if (toAdd.length > 0) {\n const headerIdxDisabledUntil = header.indexOf(disabledUntilCol);\n\n const newRows = toAdd.map((testId) => {\n const row: string[] = new Array(Math.max(testIdIdx + 1, headerIdxDisabledUntil + 1)).fill('');\n row[testIdIdx] = testId;\n if (headerIdxDisabledUntil !== -1) row[headerIdxDisabledUntil] = '';\n return row;\n });\n\n await sheets.spreadsheets.values.append({\n spreadsheetId,\n range: sheetName,\n valueInputOption: 'RAW',\n requestBody: { values: newRows },\n });\n log(`[skipper] Added ${toAdd.length} new test(s) to spreadsheet.`);\n }\n }\n}\n","import { SheetsClient } from './client';\nimport { normalizeTestId } from './cache';\nimport type { SkipperConfig, SkipperMode } from './types';\n\n/**\n * SkipperResolver is the primary interface used by framework plugins.\n *\n * Lifecycle:\n * 1. Call `initialize()` once before tests run (in globalSetup / before hook).\n * 2. Call `isTestEnabled(testId)` per test to decide whether to skip.\n * 3. In sync mode, call `toJSON()` to serialize the cache for cross-process sharing.\n * 4. In worker processes, use `SkipperResolver.fromJSON()` to rehydrate.\n */\nexport class SkipperResolver {\n private readonly client: SheetsClient;\n private readonly config: SkipperConfig;\n /** normalized testId → disabledUntil ISO string (null = no date = enabled) */\n private cache: Map<string, string | null> = new Map();\n private initialized = false;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n this.client = new SheetsClient(config);\n }\n\n /**\n * Fetches the spreadsheet and populates the in-memory cache.\n * Must be called once before `isTestEnabled()`.\n */\n async initialize(): Promise<void> {\n const { entries } = await this.client.fetchAll();\n this.cache = new Map(\n entries.map((e) => [\n normalizeTestId(e.testId),\n e.disabledUntil ? e.disabledUntil.toISOString() : null,\n ]),\n );\n this.initialized = true;\n }\n\n /**\n * Returns true if the test should run.\n *\n * Logic:\n * - Not in spreadsheet → true (opt-out model: unknown tests run by default)\n * - disabledUntil is null or in the past → true\n * - disabledUntil is in the future → false\n */\n isTestEnabled(testId: string): boolean {\n if (!this.initialized) {\n throw new Error(\n '[skipper] SkipperResolver.initialize() must be called before isTestEnabled(). ' +\n 'Did you forget to add the globalSetup to your config?',\n );\n }\n\n const normalized = normalizeTestId(testId);\n if (!this.cache.has(normalized)) return true;\n\n const iso = this.cache.get(normalized);\n if (!iso) return true;\n\n return new Date(iso) <= new Date();\n }\n\n /**\n * Serializes the cache for cross-process sharing (e.g. globalSetup → workers).\n * Dates are stored as ISO strings; null means no date (enabled).\n */\n toJSON(): Record<string, string | null> {\n return Object.fromEntries(this.cache);\n }\n\n /**\n * Rehydrates a resolver from a serialized cache.\n * Used in worker processes that cannot call initialize() again.\n */\n static fromJSON(data: Record<string, string | null>): SkipperResolver {\n // We pass a dummy config since the client is never used after fromJSON\n const resolver = new SkipperResolver({\n spreadsheetId: '',\n credentials: { credentialsBase64: '' },\n });\n resolver.cache = new Map(Object.entries(data));\n resolver.initialized = true;\n return resolver;\n }\n\n getMode(): SkipperMode {\n const mode = process.env.SKIPPER_MODE;\n if (mode === 'sync') return 'sync';\n return 'read-only';\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// src/logger.ts
|
|
2
|
+
function isEnabled() {
|
|
3
|
+
return Boolean(process.env.SKIPPER_DEBUG);
|
|
4
|
+
}
|
|
5
|
+
function log(message) {
|
|
6
|
+
if (isEnabled()) console.log(message);
|
|
7
|
+
}
|
|
8
|
+
function warn(message) {
|
|
9
|
+
if (isEnabled()) console.warn(message);
|
|
10
|
+
}
|
|
11
|
+
function error(message) {
|
|
12
|
+
if (isEnabled()) console.error(message);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/client.ts
|
|
16
|
+
import * as fs from "fs";
|
|
17
|
+
|
|
18
|
+
// src/cache.ts
|
|
19
|
+
import * as path from "path";
|
|
20
|
+
function normalizeTestId(id) {
|
|
21
|
+
return id.trim().toLowerCase().replace(/\s+/g, " ");
|
|
22
|
+
}
|
|
23
|
+
function buildTestId(filePath, titlePath) {
|
|
24
|
+
const relativePath = path.isAbsolute(filePath) ? path.relative(process.cwd(), filePath) : filePath;
|
|
25
|
+
const normalizedPath = relativePath.split(path.sep).join("/");
|
|
26
|
+
return [normalizedPath, ...titlePath].join(" > ");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/client.ts
|
|
30
|
+
function resolveCredentials(config) {
|
|
31
|
+
const { credentials } = config;
|
|
32
|
+
if ("credentialsFile" in credentials) {
|
|
33
|
+
const raw = fs.readFileSync(credentials.credentialsFile, "utf8");
|
|
34
|
+
return JSON.parse(raw);
|
|
35
|
+
}
|
|
36
|
+
if ("credentialsBase64" in credentials) {
|
|
37
|
+
const raw = Buffer.from(credentials.credentialsBase64, "base64").toString("utf8");
|
|
38
|
+
return JSON.parse(raw);
|
|
39
|
+
}
|
|
40
|
+
return credentials;
|
|
41
|
+
}
|
|
42
|
+
var SheetsClient = class {
|
|
43
|
+
constructor(config) {
|
|
44
|
+
this.config = config;
|
|
45
|
+
}
|
|
46
|
+
async fetchSheet(sheets, sheetName, sheetId) {
|
|
47
|
+
const spreadsheetId = this.config.spreadsheetId;
|
|
48
|
+
const testIdCol = this.config.testIdColumn ?? "testId";
|
|
49
|
+
const disabledUntilCol = this.config.disabledUntilColumn ?? "disabledUntil";
|
|
50
|
+
const response = await sheets.spreadsheets.values.get({ spreadsheetId, range: sheetName });
|
|
51
|
+
const rawRows = response.data.values ?? [];
|
|
52
|
+
if (rawRows.length === 0) {
|
|
53
|
+
return { sheetName, sheetId, rawRows, header: [], entries: [] };
|
|
54
|
+
}
|
|
55
|
+
const header = rawRows[0].map((h) => String(h).trim());
|
|
56
|
+
const testIdIdx = header.indexOf(testIdCol);
|
|
57
|
+
const disabledUntilIdx = header.indexOf(disabledUntilCol);
|
|
58
|
+
const notesIdx = header.indexOf("notes");
|
|
59
|
+
if (testIdIdx === -1) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`[skipper] Column "${testIdCol}" not found in sheet "${sheetName}". Found columns: ${header.join(", ")}`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const entries = [];
|
|
65
|
+
for (let i = 1; i < rawRows.length; i++) {
|
|
66
|
+
const row = rawRows[i];
|
|
67
|
+
const testId = row[testIdIdx] ? String(row[testIdIdx]).trim() : "";
|
|
68
|
+
if (!testId) continue;
|
|
69
|
+
let disabledUntil = null;
|
|
70
|
+
if (disabledUntilIdx !== -1 && row[disabledUntilIdx]) {
|
|
71
|
+
const raw = String(row[disabledUntilIdx]).trim();
|
|
72
|
+
if (raw) {
|
|
73
|
+
const parsed = new Date(raw);
|
|
74
|
+
if (!isNaN(parsed.getTime())) {
|
|
75
|
+
disabledUntil = parsed;
|
|
76
|
+
} else {
|
|
77
|
+
warn(
|
|
78
|
+
`[skipper] Row ${i + 1} in "${sheetName}": invalid date "${raw}" in "${disabledUntilCol}" \u2014 treating as enabled`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const notes = notesIdx !== -1 && row[notesIdx] ? String(row[notesIdx]) : void 0;
|
|
84
|
+
entries.push({ testId, disabledUntil, notes });
|
|
85
|
+
}
|
|
86
|
+
return { sheetName, sheetId, rawRows, header, entries };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Fetches the primary sheet and all reference sheets in a single API session.
|
|
90
|
+
*
|
|
91
|
+
* Returns:
|
|
92
|
+
* - `primary`: the primary sheet's full result (rawRows + header) for writer use
|
|
93
|
+
* - `entries`: merged test entries from all sheets (for resolver use)
|
|
94
|
+
* - `sheets`: the authenticated Sheets API client (reuse for write operations)
|
|
95
|
+
*
|
|
96
|
+
* googleapis and google-auth-library are loaded here via dynamic import so that
|
|
97
|
+
* worker processes, which only call SkipperResolver.fromJSON(), never load them.
|
|
98
|
+
*
|
|
99
|
+
* Deduplication: when the same testId appears in multiple sheets, the most
|
|
100
|
+
* restrictive (latest) disabledUntil wins.
|
|
101
|
+
*/
|
|
102
|
+
async fetchAll() {
|
|
103
|
+
const { google } = await import("googleapis");
|
|
104
|
+
const { JWT } = await import("google-auth-library");
|
|
105
|
+
const creds = resolveCredentials(this.config);
|
|
106
|
+
const auth = new JWT({
|
|
107
|
+
email: creds.client_email,
|
|
108
|
+
key: creds.private_key,
|
|
109
|
+
scopes: ["https://www.googleapis.com/auth/spreadsheets"]
|
|
110
|
+
});
|
|
111
|
+
const sheets = google.sheets({ version: "v4", auth });
|
|
112
|
+
const spreadsheetId = this.config.spreadsheetId;
|
|
113
|
+
const meta = await sheets.spreadsheets.get({ spreadsheetId });
|
|
114
|
+
const allSheetMeta = meta.data.sheets ?? [];
|
|
115
|
+
const sheetIdByName = new Map(
|
|
116
|
+
allSheetMeta.filter((s) => s.properties?.title != null && s.properties.sheetId != null).map((s) => [s.properties.title, s.properties.sheetId])
|
|
117
|
+
);
|
|
118
|
+
const primaryName = this.config.sheetName ?? allSheetMeta[0]?.properties?.title ?? "Sheet1";
|
|
119
|
+
const primaryId = sheetIdByName.get(primaryName);
|
|
120
|
+
if (primaryId == null) {
|
|
121
|
+
throw new Error(`[skipper] Sheet "${primaryName}" not found in spreadsheet.`);
|
|
122
|
+
}
|
|
123
|
+
const primary = await this.fetchSheet(sheets, primaryName, primaryId);
|
|
124
|
+
const referenceEntries = [];
|
|
125
|
+
for (const refName of this.config.referenceSheets ?? []) {
|
|
126
|
+
const refId = sheetIdByName.get(refName);
|
|
127
|
+
if (refId == null) {
|
|
128
|
+
warn(`[skipper] Reference sheet "${refName}" not found \u2014 skipping.`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const result = await this.fetchSheet(sheets, refName, refId);
|
|
132
|
+
referenceEntries.push(...result.entries);
|
|
133
|
+
}
|
|
134
|
+
const merged = /* @__PURE__ */ new Map();
|
|
135
|
+
for (const entry of [...primary.entries, ...referenceEntries]) {
|
|
136
|
+
const key = normalizeTestId(entry.testId);
|
|
137
|
+
const existing = merged.get(key);
|
|
138
|
+
if (!existing) {
|
|
139
|
+
merged.set(key, entry);
|
|
140
|
+
} else if (entry.disabledUntil !== null) {
|
|
141
|
+
if (existing.disabledUntil === null || entry.disabledUntil > existing.disabledUntil) {
|
|
142
|
+
merged.set(key, entry);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { primary, entries: [...merged.values()], sheets };
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// src/writer.ts
|
|
151
|
+
var SheetsWriter = class {
|
|
152
|
+
constructor(config) {
|
|
153
|
+
this.config = config;
|
|
154
|
+
this.client = new SheetsClient(config);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Reconciles the spreadsheet with the discovered test IDs:
|
|
158
|
+
* - Appends rows for tests not yet in the primary sheet (with empty disabledUntil)
|
|
159
|
+
* - Deletes rows for tests that no longer exist in the suite
|
|
160
|
+
*
|
|
161
|
+
* Only the primary sheet is modified. Reference sheets are never written to.
|
|
162
|
+
* Rows are matched by normalized testId (case-insensitive, whitespace-collapsed).
|
|
163
|
+
* The header row (row 1) is never modified.
|
|
164
|
+
*
|
|
165
|
+
* A single fetchAll() call is made to retrieve sheet metadata, existing entries,
|
|
166
|
+
* and raw rows — no redundant API calls.
|
|
167
|
+
*/
|
|
168
|
+
async sync(discoveredIds) {
|
|
169
|
+
const { primary, entries: existingEntries, sheets } = await this.client.fetchAll();
|
|
170
|
+
const { sheetName, sheetId, rawRows, header } = primary;
|
|
171
|
+
const testIdCol = this.config.testIdColumn ?? "testId";
|
|
172
|
+
const disabledUntilCol = this.config.disabledUntilColumn ?? "disabledUntil";
|
|
173
|
+
const normalizedDiscovered = new Set(discoveredIds.map(normalizeTestId));
|
|
174
|
+
const normalizedExisting = new Map(
|
|
175
|
+
existingEntries.map((e) => [normalizeTestId(e.testId), e])
|
|
176
|
+
);
|
|
177
|
+
const toAdd = discoveredIds.filter((id) => !normalizedExisting.has(normalizeTestId(id)));
|
|
178
|
+
const toRemoveNormalized = new Set(
|
|
179
|
+
[...normalizedExisting.keys()].filter((nid) => !normalizedDiscovered.has(nid))
|
|
180
|
+
);
|
|
181
|
+
if (toAdd.length === 0 && toRemoveNormalized.size === 0) {
|
|
182
|
+
log("[skipper] Spreadsheet is already up to date.");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const spreadsheetId = this.config.spreadsheetId;
|
|
186
|
+
const testIdIdx = header.indexOf(testIdCol);
|
|
187
|
+
const rowIndicesToDelete = [];
|
|
188
|
+
for (let i = 1; i < rawRows.length; i++) {
|
|
189
|
+
const id = rawRows[i][testIdIdx] ? String(rawRows[i][testIdIdx]).trim() : "";
|
|
190
|
+
if (id && toRemoveNormalized.has(normalizeTestId(id))) {
|
|
191
|
+
rowIndicesToDelete.push(i);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const deleteRequests = rowIndicesToDelete.sort((a, b) => b - a).map((rowIdx) => ({
|
|
195
|
+
deleteDimension: {
|
|
196
|
+
range: { sheetId, dimension: "ROWS", startIndex: rowIdx, endIndex: rowIdx + 1 }
|
|
197
|
+
}
|
|
198
|
+
}));
|
|
199
|
+
if (deleteRequests.length > 0) {
|
|
200
|
+
await sheets.spreadsheets.batchUpdate({
|
|
201
|
+
spreadsheetId,
|
|
202
|
+
requestBody: { requests: deleteRequests }
|
|
203
|
+
});
|
|
204
|
+
log(`[skipper] Removed ${deleteRequests.length} obsolete test(s) from spreadsheet.`);
|
|
205
|
+
}
|
|
206
|
+
if (toAdd.length > 0) {
|
|
207
|
+
const headerIdxDisabledUntil = header.indexOf(disabledUntilCol);
|
|
208
|
+
const newRows = toAdd.map((testId) => {
|
|
209
|
+
const row = new Array(Math.max(testIdIdx + 1, headerIdxDisabledUntil + 1)).fill("");
|
|
210
|
+
row[testIdIdx] = testId;
|
|
211
|
+
if (headerIdxDisabledUntil !== -1) row[headerIdxDisabledUntil] = "";
|
|
212
|
+
return row;
|
|
213
|
+
});
|
|
214
|
+
await sheets.spreadsheets.values.append({
|
|
215
|
+
spreadsheetId,
|
|
216
|
+
range: sheetName,
|
|
217
|
+
valueInputOption: "RAW",
|
|
218
|
+
requestBody: { values: newRows }
|
|
219
|
+
});
|
|
220
|
+
log(`[skipper] Added ${toAdd.length} new test(s) to spreadsheet.`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// src/resolver.ts
|
|
226
|
+
var SkipperResolver = class _SkipperResolver {
|
|
227
|
+
constructor(config) {
|
|
228
|
+
/** normalized testId → disabledUntil ISO string (null = no date = enabled) */
|
|
229
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
230
|
+
this.initialized = false;
|
|
231
|
+
this.config = config;
|
|
232
|
+
this.client = new SheetsClient(config);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Fetches the spreadsheet and populates the in-memory cache.
|
|
236
|
+
* Must be called once before `isTestEnabled()`.
|
|
237
|
+
*/
|
|
238
|
+
async initialize() {
|
|
239
|
+
const { entries } = await this.client.fetchAll();
|
|
240
|
+
this.cache = new Map(
|
|
241
|
+
entries.map((e) => [
|
|
242
|
+
normalizeTestId(e.testId),
|
|
243
|
+
e.disabledUntil ? e.disabledUntil.toISOString() : null
|
|
244
|
+
])
|
|
245
|
+
);
|
|
246
|
+
this.initialized = true;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Returns true if the test should run.
|
|
250
|
+
*
|
|
251
|
+
* Logic:
|
|
252
|
+
* - Not in spreadsheet → true (opt-out model: unknown tests run by default)
|
|
253
|
+
* - disabledUntil is null or in the past → true
|
|
254
|
+
* - disabledUntil is in the future → false
|
|
255
|
+
*/
|
|
256
|
+
isTestEnabled(testId) {
|
|
257
|
+
if (!this.initialized) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
"[skipper] SkipperResolver.initialize() must be called before isTestEnabled(). Did you forget to add the globalSetup to your config?"
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
const normalized = normalizeTestId(testId);
|
|
263
|
+
if (!this.cache.has(normalized)) return true;
|
|
264
|
+
const iso = this.cache.get(normalized);
|
|
265
|
+
if (!iso) return true;
|
|
266
|
+
return new Date(iso) <= /* @__PURE__ */ new Date();
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Serializes the cache for cross-process sharing (e.g. globalSetup → workers).
|
|
270
|
+
* Dates are stored as ISO strings; null means no date (enabled).
|
|
271
|
+
*/
|
|
272
|
+
toJSON() {
|
|
273
|
+
return Object.fromEntries(this.cache);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Rehydrates a resolver from a serialized cache.
|
|
277
|
+
* Used in worker processes that cannot call initialize() again.
|
|
278
|
+
*/
|
|
279
|
+
static fromJSON(data) {
|
|
280
|
+
const resolver = new _SkipperResolver({
|
|
281
|
+
spreadsheetId: "",
|
|
282
|
+
credentials: { credentialsBase64: "" }
|
|
283
|
+
});
|
|
284
|
+
resolver.cache = new Map(Object.entries(data));
|
|
285
|
+
resolver.initialized = true;
|
|
286
|
+
return resolver;
|
|
287
|
+
}
|
|
288
|
+
getMode() {
|
|
289
|
+
const mode = process.env.SKIPPER_MODE;
|
|
290
|
+
if (mode === "sync") return "sync";
|
|
291
|
+
return "read-only";
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
export {
|
|
295
|
+
SheetsClient,
|
|
296
|
+
SheetsWriter,
|
|
297
|
+
SkipperResolver,
|
|
298
|
+
buildTestId,
|
|
299
|
+
error,
|
|
300
|
+
log,
|
|
301
|
+
normalizeTestId,
|
|
302
|
+
warn
|
|
303
|
+
};
|
|
304
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/logger.ts","../src/client.ts","../src/cache.ts","../src/writer.ts","../src/resolver.ts"],"sourcesContent":["/**\n * Skipper logger — output is suppressed unless SKIPPER_DEBUG=1 (or any truthy value).\n */\n\nfunction isEnabled(): boolean {\n return Boolean(process.env.SKIPPER_DEBUG);\n}\n\nexport function log(message: string): void {\n if (isEnabled()) console.log(message);\n}\n\nexport function warn(message: string): void {\n if (isEnabled()) console.warn(message);\n}\n\nexport function error(message: string): void {\n if (isEnabled()) console.error(message);\n}\n","import * as fs from 'fs';\nimport { normalizeTestId } from './cache';\nimport { warn } from './logger';\nimport type { SkipperConfig, TestEntry, ServiceAccountCredentials } from './types';\n\n// googleapis and google-auth-library are imported dynamically inside fetchAll() so that\n// worker processes that only call SkipperResolver.fromJSON() never load these modules.\n// Loading googleapis initialises an HTTP keep-alive agent that prevents worker exit.\nimport type { sheets_v4 } from 'googleapis';\n\nfunction resolveCredentials(config: SkipperConfig): ServiceAccountCredentials {\n const { credentials } = config;\n\n if ('credentialsFile' in credentials) {\n const raw = fs.readFileSync(credentials.credentialsFile, 'utf8');\n return JSON.parse(raw) as ServiceAccountCredentials;\n }\n\n if ('credentialsBase64' in credentials) {\n const raw = Buffer.from(credentials.credentialsBase64, 'base64').toString('utf8');\n return JSON.parse(raw) as ServiceAccountCredentials;\n }\n\n return credentials as ServiceAccountCredentials;\n}\n\nexport interface SheetFetchResult {\n /** Resolved sheet tab name. */\n sheetName: string;\n /** Numeric sheet ID (used for batchUpdate deletions). */\n sheetId: number;\n /** Raw rows including the header row (row 0). */\n rawRows: string[][];\n /** Parsed header cells (trimmed). */\n header: string[];\n /** Parsed test entries. */\n entries: TestEntry[];\n}\n\nexport interface FetchAllResult {\n /** Full data for the primary (writable) sheet — used by SheetsWriter. */\n primary: SheetFetchResult;\n /** Merged entries from primary + all referenceSheets — used by SkipperResolver. */\n entries: TestEntry[];\n /**\n * Authenticated Sheets API client — returned here so callers (SheetsWriter)\n * can reuse the same auth session for write operations without a second auth call.\n */\n sheets: sheets_v4.Sheets;\n}\n\nexport class SheetsClient {\n private readonly config: SkipperConfig;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n }\n\n private async fetchSheet(\n sheets: sheets_v4.Sheets,\n sheetName: string,\n sheetId: number,\n ): Promise<SheetFetchResult> {\n const spreadsheetId = this.config.spreadsheetId;\n const testIdCol = this.config.testIdColumn ?? 'testId';\n const disabledUntilCol = this.config.disabledUntilColumn ?? 'disabledUntil';\n\n const response = await sheets.spreadsheets.values.get({ spreadsheetId, range: sheetName });\n const rawRows = (response.data.values ?? []) as string[][];\n\n if (rawRows.length === 0) {\n return { sheetName, sheetId, rawRows, header: [], entries: [] };\n }\n\n const header = rawRows[0].map((h: string) => String(h).trim());\n const testIdIdx = header.indexOf(testIdCol);\n const disabledUntilIdx = header.indexOf(disabledUntilCol);\n const notesIdx = header.indexOf('notes');\n\n if (testIdIdx === -1) {\n throw new Error(\n `[skipper] Column \"${testIdCol}\" not found in sheet \"${sheetName}\". ` +\n `Found columns: ${header.join(', ')}`,\n );\n }\n\n const entries: TestEntry[] = [];\n for (let i = 1; i < rawRows.length; i++) {\n const row = rawRows[i];\n const testId = row[testIdIdx] ? String(row[testIdIdx]).trim() : '';\n if (!testId) continue;\n\n let disabledUntil: Date | null = null;\n if (disabledUntilIdx !== -1 && row[disabledUntilIdx]) {\n const raw = String(row[disabledUntilIdx]).trim();\n if (raw) {\n const parsed = new Date(raw);\n if (!isNaN(parsed.getTime())) {\n disabledUntil = parsed;\n } else {\n warn(\n `[skipper] Row ${i + 1} in \"${sheetName}\": invalid date \"${raw}\" in \"${disabledUntilCol}\" — treating as enabled`,\n );\n }\n }\n }\n\n const notes = notesIdx !== -1 && row[notesIdx] ? String(row[notesIdx]) : undefined;\n entries.push({ testId, disabledUntil, notes });\n }\n\n return { sheetName, sheetId, rawRows, header, entries };\n }\n\n /**\n * Fetches the primary sheet and all reference sheets in a single API session.\n *\n * Returns:\n * - `primary`: the primary sheet's full result (rawRows + header) for writer use\n * - `entries`: merged test entries from all sheets (for resolver use)\n * - `sheets`: the authenticated Sheets API client (reuse for write operations)\n *\n * googleapis and google-auth-library are loaded here via dynamic import so that\n * worker processes, which only call SkipperResolver.fromJSON(), never load them.\n *\n * Deduplication: when the same testId appears in multiple sheets, the most\n * restrictive (latest) disabledUntil wins.\n */\n async fetchAll(): Promise<FetchAllResult> {\n // Dynamic imports — only executed when actually fetching from the spreadsheet.\n const { google } = await import('googleapis');\n const { JWT } = await import('google-auth-library');\n\n const creds = resolveCredentials(this.config);\n const auth = new JWT({\n email: creds.client_email,\n key: creds.private_key,\n scopes: ['https://www.googleapis.com/auth/spreadsheets'],\n });\n const sheets = google.sheets({ version: 'v4', auth });\n\n const spreadsheetId = this.config.spreadsheetId;\n const meta = await sheets.spreadsheets.get({ spreadsheetId });\n const allSheetMeta = meta.data.sheets ?? [];\n\n const sheetIdByName = new Map<string, number>(\n allSheetMeta\n .filter((s) => s.properties?.title != null && s.properties.sheetId != null)\n .map((s) => [s.properties!.title!, s.properties!.sheetId!]),\n );\n\n const primaryName = this.config.sheetName ?? allSheetMeta[0]?.properties?.title ?? 'Sheet1';\n const primaryId = sheetIdByName.get(primaryName);\n if (primaryId == null) {\n throw new Error(`[skipper] Sheet \"${primaryName}\" not found in spreadsheet.`);\n }\n\n const primary = await this.fetchSheet(sheets, primaryName, primaryId);\n\n const referenceEntries: TestEntry[] = [];\n for (const refName of this.config.referenceSheets ?? []) {\n const refId = sheetIdByName.get(refName);\n if (refId == null) {\n warn(`[skipper] Reference sheet \"${refName}\" not found — skipping.`);\n continue;\n }\n const result = await this.fetchSheet(sheets, refName, refId);\n referenceEntries.push(...result.entries);\n }\n\n const merged = new Map<string, TestEntry>();\n for (const entry of [...primary.entries, ...referenceEntries]) {\n const key = normalizeTestId(entry.testId);\n const existing = merged.get(key);\n if (!existing) {\n merged.set(key, entry);\n } else if (entry.disabledUntil !== null) {\n if (existing.disabledUntil === null || entry.disabledUntil > existing.disabledUntil) {\n merged.set(key, entry);\n }\n }\n }\n\n return { primary, entries: [...merged.values()], sheets };\n }\n}\n","import * as path from 'path';\n\n/**\n * Normalizes a testId for consistent comparison:\n * - trim leading/trailing whitespace\n * - lowercase\n * - collapse multiple whitespace characters into a single space\n */\nexport function normalizeTestId(id: string): string {\n return id.trim().toLowerCase().replace(/\\s+/g, ' ');\n}\n\n/**\n * Builds a canonical testId from a file path and the test title path.\n *\n * Format: \"{relativePath} > {titlePath.join(' > ')}\"\n * Example: \"tests/auth/login.spec.ts > login > should log in with valid credentials\"\n *\n * The filePath is made relative to process.cwd() if it is absolute.\n * The titlePath is the array of describe block names + the test name,\n * as provided by the test framework (never pre-joined).\n */\nexport function buildTestId(filePath: string, titlePath: string[]): string {\n const relativePath = path.isAbsolute(filePath)\n ? path.relative(process.cwd(), filePath)\n : filePath;\n\n const normalizedPath = relativePath.split(path.sep).join('/');\n return [normalizedPath, ...titlePath].join(' > ');\n}\n","import { SheetsClient } from './client';\nimport { normalizeTestId } from './cache';\nimport { log } from './logger';\nimport type { SkipperConfig, TestEntry } from './types';\n\nexport class SheetsWriter {\n private readonly client: SheetsClient;\n private readonly config: SkipperConfig;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n this.client = new SheetsClient(config);\n }\n\n /**\n * Reconciles the spreadsheet with the discovered test IDs:\n * - Appends rows for tests not yet in the primary sheet (with empty disabledUntil)\n * - Deletes rows for tests that no longer exist in the suite\n *\n * Only the primary sheet is modified. Reference sheets are never written to.\n * Rows are matched by normalized testId (case-insensitive, whitespace-collapsed).\n * The header row (row 1) is never modified.\n *\n * A single fetchAll() call is made to retrieve sheet metadata, existing entries,\n * and raw rows — no redundant API calls.\n */\n async sync(discoveredIds: string[]): Promise<void> {\n // One fetchAll() resolves the sheet name from metadata, fetches existing\n // entries, and returns raw rows and the authenticated Sheets client —\n // all in two API calls (metadata + values). No second auth/fetch needed.\n const { primary, entries: existingEntries, sheets } = await this.client.fetchAll();\n const { sheetName, sheetId, rawRows, header } = primary;\n\n const testIdCol = this.config.testIdColumn ?? 'testId';\n const disabledUntilCol = this.config.disabledUntilColumn ?? 'disabledUntil';\n\n const normalizedDiscovered = new Set(discoveredIds.map(normalizeTestId));\n const normalizedExisting = new Map<string, TestEntry>(\n existingEntries.map((e) => [normalizeTestId(e.testId), e]),\n );\n\n const toAdd = discoveredIds.filter((id) => !normalizedExisting.has(normalizeTestId(id)));\n const toRemoveNormalized = new Set(\n [...normalizedExisting.keys()].filter((nid) => !normalizedDiscovered.has(nid)),\n );\n\n if (toAdd.length === 0 && toRemoveNormalized.size === 0) {\n log('[skipper] Spreadsheet is already up to date.');\n return;\n }\n\n const spreadsheetId = this.config.spreadsheetId;\n\n const testIdIdx = header.indexOf(testIdCol);\n\n // Identify 0-based row indices (within rawRows) to delete, skipping header at 0.\n const rowIndicesToDelete: number[] = [];\n for (let i = 1; i < rawRows.length; i++) {\n const id = rawRows[i][testIdIdx] ? String(rawRows[i][testIdIdx]).trim() : '';\n if (id && toRemoveNormalized.has(normalizeTestId(id))) {\n rowIndicesToDelete.push(i);\n }\n }\n\n // Deletions must be sorted descending to avoid index shifting.\n const deleteRequests = rowIndicesToDelete\n .sort((a, b) => b - a)\n .map((rowIdx) => ({\n deleteDimension: {\n range: { sheetId, dimension: 'ROWS', startIndex: rowIdx, endIndex: rowIdx + 1 },\n },\n }));\n\n if (deleteRequests.length > 0) {\n await sheets.spreadsheets.batchUpdate({\n spreadsheetId,\n requestBody: { requests: deleteRequests },\n });\n log(`[skipper] Removed ${deleteRequests.length} obsolete test(s) from spreadsheet.`);\n }\n\n // Append new rows.\n if (toAdd.length > 0) {\n const headerIdxDisabledUntil = header.indexOf(disabledUntilCol);\n\n const newRows = toAdd.map((testId) => {\n const row: string[] = new Array(Math.max(testIdIdx + 1, headerIdxDisabledUntil + 1)).fill('');\n row[testIdIdx] = testId;\n if (headerIdxDisabledUntil !== -1) row[headerIdxDisabledUntil] = '';\n return row;\n });\n\n await sheets.spreadsheets.values.append({\n spreadsheetId,\n range: sheetName,\n valueInputOption: 'RAW',\n requestBody: { values: newRows },\n });\n log(`[skipper] Added ${toAdd.length} new test(s) to spreadsheet.`);\n }\n }\n}\n","import { SheetsClient } from './client';\nimport { normalizeTestId } from './cache';\nimport type { SkipperConfig, SkipperMode } from './types';\n\n/**\n * SkipperResolver is the primary interface used by framework plugins.\n *\n * Lifecycle:\n * 1. Call `initialize()` once before tests run (in globalSetup / before hook).\n * 2. Call `isTestEnabled(testId)` per test to decide whether to skip.\n * 3. In sync mode, call `toJSON()` to serialize the cache for cross-process sharing.\n * 4. In worker processes, use `SkipperResolver.fromJSON()` to rehydrate.\n */\nexport class SkipperResolver {\n private readonly client: SheetsClient;\n private readonly config: SkipperConfig;\n /** normalized testId → disabledUntil ISO string (null = no date = enabled) */\n private cache: Map<string, string | null> = new Map();\n private initialized = false;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n this.client = new SheetsClient(config);\n }\n\n /**\n * Fetches the spreadsheet and populates the in-memory cache.\n * Must be called once before `isTestEnabled()`.\n */\n async initialize(): Promise<void> {\n const { entries } = await this.client.fetchAll();\n this.cache = new Map(\n entries.map((e) => [\n normalizeTestId(e.testId),\n e.disabledUntil ? e.disabledUntil.toISOString() : null,\n ]),\n );\n this.initialized = true;\n }\n\n /**\n * Returns true if the test should run.\n *\n * Logic:\n * - Not in spreadsheet → true (opt-out model: unknown tests run by default)\n * - disabledUntil is null or in the past → true\n * - disabledUntil is in the future → false\n */\n isTestEnabled(testId: string): boolean {\n if (!this.initialized) {\n throw new Error(\n '[skipper] SkipperResolver.initialize() must be called before isTestEnabled(). ' +\n 'Did you forget to add the globalSetup to your config?',\n );\n }\n\n const normalized = normalizeTestId(testId);\n if (!this.cache.has(normalized)) return true;\n\n const iso = this.cache.get(normalized);\n if (!iso) return true;\n\n return new Date(iso) <= new Date();\n }\n\n /**\n * Serializes the cache for cross-process sharing (e.g. globalSetup → workers).\n * Dates are stored as ISO strings; null means no date (enabled).\n */\n toJSON(): Record<string, string | null> {\n return Object.fromEntries(this.cache);\n }\n\n /**\n * Rehydrates a resolver from a serialized cache.\n * Used in worker processes that cannot call initialize() again.\n */\n static fromJSON(data: Record<string, string | null>): SkipperResolver {\n // We pass a dummy config since the client is never used after fromJSON\n const resolver = new SkipperResolver({\n spreadsheetId: '',\n credentials: { credentialsBase64: '' },\n });\n resolver.cache = new Map(Object.entries(data));\n resolver.initialized = true;\n return resolver;\n }\n\n getMode(): SkipperMode {\n const mode = process.env.SKIPPER_MODE;\n if (mode === 'sync') return 'sync';\n return 'read-only';\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@get-skipper/core",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Core Google Sheets client and resolver for Skipper test-gating plugins",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"skipper",
|
|
7
|
+
"testing",
|
|
8
|
+
"google-sheets",
|
|
9
|
+
"test-gating"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"module": "dist/index.mjs",
|
|
14
|
+
"types": "dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.mjs",
|
|
19
|
+
"require": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"googleapis": "^140.0.0",
|
|
29
|
+
"google-auth-library": "^9.6.0"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsup",
|
|
36
|
+
"dev": "tsup --watch",
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"test": "jest --config ../../jest.config.js --testPathPattern=packages/core/"
|
|
39
|
+
}
|
|
40
|
+
}
|