@govtechsg/oobee 0.10.89 → 0.10.90

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.
@@ -28,19 +28,28 @@ jobs:
28
28
  current_version=$(node -p "require('./package.json').version")
29
29
  echo "version=$current_version" >> "$GITHUB_OUTPUT"
30
30
 
31
+ - name: Install dependencies
32
+ run: npm ci
33
+
34
+ - name: Build
35
+ run: npm run build
36
+
31
37
  - name: Bump patch version
32
38
  id: bump
33
39
  run: |
34
40
  new_version=$(npm version patch --no-git-tag-version)
35
41
  echo "version=$new_version" >> "$GITHUB_OUTPUT"
36
42
 
43
+ - name: Generate oobee-client-scanner.js
44
+ run: node dist/generateOobeeClientScanner.js
45
+
37
46
  - name: Create branch and commit changes
38
47
  run: |
39
48
  BRANCH="bump/version-${{ steps.bump.outputs.version }}"
40
49
  git config user.name "github-actions[bot]"
41
50
  git config user.email "github-actions[bot]@users.noreply.github.com"
42
51
  git checkout -b "$BRANCH"
43
- git add package.json package-lock.json
52
+ git add package.json package-lock.json oobee-client-scanner.js
44
53
  git commit -m "chore: bump version ${{ steps.current.outputs.version }} → ${{ steps.bump.outputs.version }}"
45
54
  git push origin "$BRANCH"
46
55
  echo "BRANCH=$BRANCH" >> $GITHUB_ENV
@@ -7,6 +7,8 @@ on:
7
7
  description: 'Release tag for the image (e.g. 0.10.87)'
8
8
  required: true
9
9
  type: string
10
+ release:
11
+ types: [published]
10
12
 
11
13
  permissions:
12
14
  contents: read
@@ -15,6 +17,8 @@ permissions:
15
17
  jobs:
16
18
  build-and-push:
17
19
  runs-on: ubuntu-latest
20
+ env:
21
+ RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}
18
22
  steps:
19
23
  - name: Checkout code
20
24
  uses: actions/checkout@v4
@@ -43,7 +47,7 @@ jobs:
43
47
  platforms: linux/amd64,linux/arm64
44
48
  push: true
45
49
  tags: |
46
- ghcr.io/${{ steps.repo.outputs.name }}:${{ inputs.release_tag }}
50
+ ghcr.io/${{ steps.repo.outputs.name }}:${{ env.RELEASE_TAG }}
47
51
  ghcr.io/${{ steps.repo.outputs.name }}:latest
48
52
  cache-from: type=gha
49
53
  cache-to: type=gha,mode=max
@@ -22,13 +22,18 @@ jobs:
22
22
  - run: npm run build
23
23
  continue-on-error: false
24
24
 
25
- - name: Create and push git tag
25
+ - name: Generate oobee-client-scanner.js
26
+ run: node dist/generateOobeeClientScanner.js
27
+
28
+ - name: Create and push git tags
26
29
  run: |
27
30
  VERSION=$(node -p "require('./package.json').version")
28
31
  git config user.name "github-actions[bot]"
29
32
  git config user.email "github-actions[bot]@users.noreply.github.com"
30
- git tag -af "v${VERSION}" -m "Version ${VERSION}"
31
- git push origin "v${VERSION}" --force
33
+ git tag -af "${VERSION}" -m "Version ${VERSION}"
34
+ git push origin "${VERSION}" --force
35
+ git tag -af "latest" -m "Latest release ${VERSION}"
36
+ git push origin "latest" --force
32
37
 
33
38
  - run: npm publish
34
39
  env:
package/README.md CHANGED
@@ -6,6 +6,8 @@ This is the engine and command-line interface (CLI) for Oobee. The official appl
6
6
 
7
7
  For the **user-friendly desktop application**, check out [Oobee](https://go.gov.sg/oobee).
8
8
 
9
+ [![DPG Badge](https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==)](https://digitalpublicgoods.net/r/oobee)
10
+
9
11
  ## Technology Stack
10
12
 
11
13
  1. [Crawlee](https://crawlee.dev/)
@@ -0,0 +1,51 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import readline from 'readline';
4
+ export class ItemsStore {
5
+ constructor(storagePath) {
6
+ this.ensuredDirs = new Set();
7
+ this.basePath = path.join(storagePath, 'tmp-items');
8
+ }
9
+ sanitizeRuleId(ruleId) {
10
+ return ruleId.replace(/[^a-zA-Z0-9_-]/g, '_');
11
+ }
12
+ getRuleFilePath(category, ruleId) {
13
+ return path.join(this.basePath, category, `${this.sanitizeRuleId(ruleId)}.jsonl`);
14
+ }
15
+ async ensureDir(category) {
16
+ const dirPath = path.join(this.basePath, category);
17
+ if (!this.ensuredDirs.has(dirPath)) {
18
+ await fs.ensureDir(dirPath);
19
+ this.ensuredDirs.add(dirPath);
20
+ }
21
+ }
22
+ async appendPageItems(category, ruleId, entry) {
23
+ await this.ensureDir(category);
24
+ const filePath = this.getRuleFilePath(category, ruleId);
25
+ const line = JSON.stringify(entry) + '\n';
26
+ await fs.appendFile(filePath, line, 'utf8');
27
+ }
28
+ async *readRuleItems(category, ruleId) {
29
+ const filePath = this.getRuleFilePath(category, ruleId);
30
+ if (!fs.existsSync(filePath))
31
+ return;
32
+ const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
33
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
34
+ for await (const line of rl) {
35
+ if (line.trim()) {
36
+ yield JSON.parse(line);
37
+ }
38
+ }
39
+ }
40
+ async readRuleItemsMap(category, ruleId) {
41
+ const map = new Map();
42
+ for await (const entry of this.readRuleItems(category, ruleId)) {
43
+ const key = entry.pageIndex != null ? String(entry.pageIndex) : entry.url;
44
+ map.set(key, entry);
45
+ }
46
+ return map;
47
+ }
48
+ async cleanup() {
49
+ await fs.rm(this.basePath, { recursive: true, force: true });
50
+ }
51
+ }
@@ -67,140 +67,130 @@ function writeLargeJsonToFile(obj, filePath) {
67
67
  write();
68
68
  });
69
69
  }
70
- const writeLargeScanItemsJsonToFile = async (obj, filePath) => {
71
- return new Promise((resolve, reject) => {
72
- const writeStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf8' });
73
- const writeQueue = [];
74
- let isWriting = false;
75
- const processNextWrite = async () => {
76
- if (isWriting || writeQueue.length === 0)
77
- return;
78
- isWriting = true;
79
- const data = writeQueue.shift();
80
- try {
81
- if (!writeStream.write(data)) {
82
- await new Promise(resolve => {
83
- writeStream.once('drain', () => {
84
- resolve();
85
- });
86
- });
87
- }
88
- }
89
- catch (error) {
90
- writeStream.destroy(error);
91
- return;
70
+ const writeLargeScanItemsJsonToFile = async (obj, filePath, itemsStore) => {
71
+ const writeStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf8' });
72
+ const write = (data) => {
73
+ if (!writeStream.write(data)) {
74
+ return new Promise(resolve => writeStream.once('drain', resolve));
75
+ }
76
+ return Promise.resolve();
77
+ };
78
+ try {
79
+ await write('{\n');
80
+ const keys = Object.keys(obj);
81
+ for (let i = 0; i < keys.length; i++) {
82
+ const key = keys[i];
83
+ const value = obj[key];
84
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
85
+ await write(` "${key}": ${JSON.stringify(value)}`);
92
86
  }
93
- isWriting = false;
94
- processNextWrite();
95
- };
96
- const queueWrite = (data) => {
97
- writeQueue.push(data);
98
- processNextWrite();
99
- };
100
- writeStream.on('error', error => {
101
- consoleLogger.error(`Error writing object to JSON file: ${error}`);
102
- reject(error);
103
- });
104
- writeStream.on('finish', () => {
105
- consoleLogger.info(`JSON file written successfully: ${filePath}`);
106
- resolve(true);
107
- });
108
- try {
109
- queueWrite('{\n');
110
- const keys = Object.keys(obj);
111
- keys.forEach((key, i) => {
112
- const value = obj[key];
113
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
114
- queueWrite(` "${key}": ${JSON.stringify(value)}`);
87
+ else {
88
+ await write(` "${key}": {\n`);
89
+ const { rules, ...otherProperties } = value;
90
+ const otherKeys = Object.keys(otherProperties);
91
+ for (let j = 0; j < otherKeys.length; j++) {
92
+ const propKey = otherKeys[j];
93
+ const propValue = otherProperties[propKey];
94
+ const propValueString = propValue === null ||
95
+ typeof propValue === 'function' ||
96
+ typeof propValue === 'undefined'
97
+ ? 'null'
98
+ : JSON.stringify(propValue);
99
+ await write(` "${propKey}": ${propValueString}`);
100
+ if (j < otherKeys.length - 1 || (rules && rules.length >= 0)) {
101
+ await write(',\n');
102
+ }
103
+ else {
104
+ await write('\n');
105
+ }
115
106
  }
116
- else {
117
- queueWrite(` "${key}": {\n`);
118
- const { rules, ...otherProperties } = value;
119
- Object.entries(otherProperties).forEach(([propKey, propValue], j) => {
120
- const propValueString = propValue === null ||
121
- typeof propValue === 'function' ||
122
- typeof propValue === 'undefined'
123
- ? 'null'
124
- : JSON.stringify(propValue);
125
- queueWrite(` "${propKey}": ${propValueString}`);
126
- if (j < Object.keys(otherProperties).length - 1 || (rules && rules.length >= 0)) {
127
- queueWrite(',\n');
128
- }
129
- else {
130
- queueWrite('\n');
107
+ if (rules && Array.isArray(rules)) {
108
+ await write(' "rules": [\n');
109
+ for (let j = 0; j < rules.length; j++) {
110
+ const rule = rules[j];
111
+ await write(' {\n');
112
+ const { pagesAffected, ...otherRuleProperties } = rule;
113
+ const ruleKeys = Object.keys(otherRuleProperties);
114
+ for (let k = 0; k < ruleKeys.length; k++) {
115
+ const ruleKey = ruleKeys[k];
116
+ const ruleValue = otherRuleProperties[ruleKey];
117
+ const ruleValueString = ruleValue === null ||
118
+ typeof ruleValue === 'function' ||
119
+ typeof ruleValue === 'undefined'
120
+ ? 'null'
121
+ : JSON.stringify(ruleValue);
122
+ await write(` "${ruleKey}": ${ruleValueString}`);
123
+ if (k < ruleKeys.length - 1 || pagesAffected) {
124
+ await write(',\n');
125
+ }
126
+ else {
127
+ await write('\n');
128
+ }
131
129
  }
132
- });
133
- if (rules && Array.isArray(rules)) {
134
- queueWrite(' "rules": [\n');
135
- rules.forEach((rule, j) => {
136
- queueWrite(' {\n');
137
- const { pagesAffected, ...otherRuleProperties } = rule;
138
- Object.entries(otherRuleProperties).forEach(([ruleKey, ruleValue], k) => {
139
- const ruleValueString = ruleValue === null ||
140
- typeof ruleValue === 'function' ||
141
- typeof ruleValue === 'undefined'
142
- ? 'null'
143
- : JSON.stringify(ruleValue);
144
- queueWrite(` "${ruleKey}": ${ruleValueString}`);
145
- if (k < Object.keys(otherRuleProperties).length - 1 || pagesAffected) {
146
- queueWrite(',\n');
130
+ if (pagesAffected && Array.isArray(pagesAffected)) {
131
+ // Load items from disk for this rule if itemsStore is available
132
+ let itemsMap = null;
133
+ if (itemsStore && rule.rule) {
134
+ itemsMap = await itemsStore.readRuleItemsMap(key, rule.rule);
135
+ }
136
+ await write(' "pagesAffected": [\n');
137
+ for (let p = 0; p < pagesAffected.length; p++) {
138
+ const page = pagesAffected[p];
139
+ let fullPage = page;
140
+ if (itemsMap) {
141
+ const lookupKey = page.pageIndex != null ? String(page.pageIndex) : page.url;
142
+ const entry = itemsMap.get(lookupKey);
143
+ if (entry) {
144
+ // Strip itemsCount to match original scanItems.json format
145
+ const { itemsCount: _ic, ...pageWithoutCount } = page;
146
+ fullPage = { ...pageWithoutCount, items: entry.items };
147
+ }
148
+ }
149
+ const pageJson = JSON.stringify(fullPage, null, 2)
150
+ .split('\n')
151
+ .map(line => ` ${line}`)
152
+ .join('\n');
153
+ await write(pageJson);
154
+ if (p < pagesAffected.length - 1) {
155
+ await write(',\n');
147
156
  }
148
157
  else {
149
- queueWrite('\n');
158
+ await write('\n');
150
159
  }
151
- });
152
- if (pagesAffected && Array.isArray(pagesAffected)) {
153
- queueWrite(' "pagesAffected": [\n');
154
- pagesAffected.forEach((page, p) => {
155
- const pageJson = JSON.stringify(page, null, 2)
156
- .split('\n')
157
- .map(line => ` ${line}`)
158
- .join('\n');
159
- queueWrite(pageJson);
160
- if (p < pagesAffected.length - 1) {
161
- queueWrite(',\n');
162
- }
163
- else {
164
- queueWrite('\n');
165
- }
166
- });
167
- queueWrite(' ]');
168
- }
169
- queueWrite('\n }');
170
- if (j < rules.length - 1) {
171
- queueWrite(',\n');
172
- }
173
- else {
174
- queueWrite('\n');
175
160
  }
176
- });
177
- queueWrite(' ]');
161
+ await write(' ]');
162
+ }
163
+ await write('\n }');
164
+ if (j < rules.length - 1) {
165
+ await write(',\n');
166
+ }
167
+ else {
168
+ await write('\n');
169
+ }
178
170
  }
179
- queueWrite('\n }');
180
- }
181
- if (i < keys.length - 1) {
182
- queueWrite(',\n');
183
- }
184
- else {
185
- queueWrite('\n');
171
+ await write(' ]');
186
172
  }
187
- });
188
- queueWrite('}\n');
189
- const checkQueueAndEnd = () => {
190
- if (writeQueue.length === 0 && !isWriting) {
191
- writeStream.end();
192
- }
193
- else {
194
- setTimeout(checkQueueAndEnd, 100);
195
- }
196
- };
197
- checkQueueAndEnd();
198
- }
199
- catch (err) {
200
- writeStream.destroy(err);
201
- reject(err);
173
+ await write('\n }');
174
+ }
175
+ if (i < keys.length - 1) {
176
+ await write(',\n');
177
+ }
178
+ else {
179
+ await write('\n');
180
+ }
202
181
  }
203
- });
182
+ await write('}\n');
183
+ }
184
+ finally {
185
+ writeStream.end();
186
+ await new Promise((resolve, reject) => {
187
+ writeStream.on('finish', () => {
188
+ consoleLogger.info(`JSON file written successfully: ${filePath}`);
189
+ resolve();
190
+ });
191
+ writeStream.on('error', reject);
192
+ });
193
+ }
204
194
  };
205
195
  async function compressJsonFileStreaming(inputPath, outputPath) {
206
196
  const readStream = fs.createReadStream(inputPath);
@@ -210,12 +200,12 @@ async function compressJsonFileStreaming(inputPath, outputPath) {
210
200
  await pipeline(readStream, gzip, base64Encode, writeStream);
211
201
  consoleLogger.info(`File successfully compressed and saved to ${outputPath}`);
212
202
  }
213
- const writeJsonFileAndCompressedJsonFile = async (data, storagePath, filename) => {
203
+ const writeJsonFileAndCompressedJsonFile = async (data, storagePath, filename, itemsStore) => {
214
204
  try {
215
205
  consoleLogger.info(`Writing JSON to ${filename}.json`);
216
206
  const jsonFilePath = path.join(storagePath, `${filename}.json`);
217
207
  if (filename === 'scanItems') {
218
- await writeLargeScanItemsJsonToFile(data, jsonFilePath);
208
+ await writeLargeScanItemsJsonToFile(data, jsonFilePath, itemsStore);
219
209
  }
220
210
  else {
221
211
  await writeLargeJsonToFile(data, jsonFilePath);
@@ -234,10 +224,10 @@ const writeJsonFileAndCompressedJsonFile = async (data, storagePath, filename) =
234
224
  throw error;
235
225
  }
236
226
  };
237
- const writeJsonAndBase64Files = async (allIssues, storagePath) => {
227
+ const writeJsonAndBase64Files = async (allIssues, storagePath, itemsStore) => {
238
228
  const { items, ...rest } = allIssues;
239
229
  const { jsonFilePath: scanDataJsonFilePath, base64FilePath: scanDataBase64FilePath } = await writeJsonFileAndCompressedJsonFile(rest, storagePath, 'scanData');
240
- const { jsonFilePath: scanItemsJsonFilePath, base64FilePath: scanItemsBase64FilePath } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...items }, storagePath, 'scanItems');
230
+ const { jsonFilePath: scanItemsJsonFilePath, base64FilePath: scanItemsBase64FilePath } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...items }, storagePath, 'scanItems', itemsStore);
241
231
  ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
242
232
  if (items[category].rules && Array.isArray(items[category].rules)) {
243
233
  items[category].rules.forEach(rule => {
@@ -267,22 +257,22 @@ const writeJsonAndBase64Files = async (allIssues, storagePath) => {
267
257
  const { jsonFilePath: scanIssuesSummaryJsonFilePath, base64FilePath: scanIssuesSummaryBase64FilePath, } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...scanIssuesSummary }, storagePath, 'scanIssuesSummary');
268
258
  items.mustFix.rules.forEach(rule => {
269
259
  rule.pagesAffected.forEach(page => {
270
- page.itemsCount = page.items.length;
260
+ page.itemsCount = page.itemsCount ?? (Array.isArray(page.items) ? page.items.length : 0);
271
261
  });
272
262
  });
273
263
  items.goodToFix.rules.forEach(rule => {
274
264
  rule.pagesAffected.forEach(page => {
275
- page.itemsCount = page.items.length;
265
+ page.itemsCount = page.itemsCount ?? (Array.isArray(page.items) ? page.items.length : 0);
276
266
  });
277
267
  });
278
268
  items.needsReview.rules.forEach(rule => {
279
269
  rule.pagesAffected.forEach(page => {
280
- page.itemsCount = page.items.length;
270
+ page.itemsCount = page.itemsCount ?? (Array.isArray(page.items) ? page.items.length : 0);
281
271
  });
282
272
  });
283
273
  items.passed.rules.forEach(rule => {
284
274
  rule.pagesAffected.forEach(page => {
285
- page.itemsCount = page.items.length;
275
+ page.itemsCount = page.itemsCount ?? (Array.isArray(page.items) ? page.items.length : 0);
286
276
  });
287
277
  });
288
278
  items.mustFix.totalRuleIssues = items.mustFix.rules.length;
@@ -15,8 +15,8 @@ export default function populateScanPagesDetail(allIssues) {
15
15
  categoryData.rules.forEach(rule => {
16
16
  const { rule: ruleId, conformance = [] } = rule;
17
17
  rule.pagesAffected.forEach(p => {
18
- const { url, pageTitle, items = [] } = p;
19
- const itemsCount = items.length;
18
+ const { url, pageTitle, itemsCount: storedCount, items } = p;
19
+ const itemsCount = storedCount ?? (Array.isArray(items) ? items.length : 0);
20
20
  if (!pagesMap[url]) {
21
21
  pagesMap[url] = {
22
22
  pageTitle,