Compare commits

...

8 commits

Author SHA1 Message Date
ThatGuySam
a05f8607b8 docs: record lockfile outcome after axios removal
Some checks failed
Deploy to Cloudflare Workers with Wrangler / Deploy (push) Has been cancelled
Run Node 24 Checks / build (24.x) (push) Has been cancelled
Complete the axios-removal plan note that only transitive gaxios remains in lockfile and document policy decision for merge completion.
2026-04-06 12:26:27 -05:00
ThatGuySam
fd3ade1ad9 gitignore: ignore docs/data for OMX workspace artifacts
Keep  untracked so OMX-driven planning and research data remains local while keeping history clean.
2026-04-06 12:26:21 -05:00
ThatGuySam
b6e87d7e46 docs: add trunk-based development guidance
Record repo guidance to perform trunk-based development on master and avoid long-lived feature branches.
2026-04-06 12:22:24 -05:00
ThatGuySam
ed767819aa Merge axios-removal work into master 2026-04-06 12:22:20 -05:00
ThatGuySam
d45b587434 Finish axios migration via shared native HTTP helper
Replace all in-scope axios callsites with a new helpers/http.js wrapper over native fetch, including JSON/text GET, JSON POST, HEAD checks, and transient 5xx retry behavior; update all browser, build, script, and proxy API clients to use it; add focused unit tests; and remove axios from package dependencies.

Constraint: Preserve API/build and deployment behavior while lowering transport surface area.

Rejected: inline fetch replacements at each callsite | rejected to avoid inconsistent error/retry semantics.

Confidence: high

Scope-risk: moderate

Directive: Keep helper in place as the transport boundary and update tests when changing request semantics.

Tested: pnpm run -s typecheck, pnpm -s run test-prebuild, pnpm -s run test, pnpm -s run test:browser, pnpm -s run netlify-build, smoke GETs on /apple-silicon-app-test and /apple-silicon-app-test/?version=2

Not-tested: branch/netlify deployment health in CI pipeline after merge
2026-04-06 12:09:16 -05:00
ThatGuySam
d39a2a1d6c Bundle fallback data into SSR instead of reading repo-local files
The previous route fallback fix worked locally but still failed on production because the Netlify SSR runtime did not have repo-local JSON files available at the paths the helper searched.

Switch the fallback helper to raw-import the generated app, game, device, and YouTube JSON inputs so the SSR bundle carries the data it needs at runtime, independent of function working directory or file packaging quirks.

Constraint: Netlify SSR bundling does not reliably expose repo-local generated files as runtime-readable filesystem paths
Rejected: Rely on Netlify included_files for SSR bundle data | the generated SSR function archive still omitted the fallback files
Rejected: Fetch large fallback JSON over HTTP on each request | unnecessary network dependency for a server-side fallback path
Confidence: medium
Scope-risk: narrow
Reversibility: clean
Directive: Prefer bundler-native inclusion for SSR-only fallback data when runtime file availability is uncertain on Netlify
Tested: vitest ./test/prebuild/config-node.test.js ./test/prebuild/site-listings.test.js; pnpm run netlify-build
Not-tested: live production after redeploy
2026-04-06 11:00:53 -05:00
ThatGuySam
6cfbfbf530 Keep prod health checks and route fallbacks from failing on stale API entries
Add a Bun health script that exercises top-level, dynamic, and representative video routes against one or more hosts so prod regressions are visible from a single command.

Device pages now fall back to the bundled device list when the external API misses a slug, and orphaned tv slugs redirect to /benchmarks instead of returning a 500. Video fallback logic reuses the existing YouTube-to-listing builder so route reconstruction stays aligned with the current build logic.

Constraint: The external API host can lag behind the frontend build and omit per-slug JSON files that public routes still expect
Rejected: Import the generated video list directly | static/video-list.json is too large for a safe SSR fallback
Rejected: Leave missing tv routes as 500s | a stale public URL should degrade to a useful redirect instead of breaking the request
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep route fallbacks tied to build-time artifacts from the same repo so frontend and fallback data stay in sync
Tested: bun scripts/health http://127.0.0.1:4322; vitest ./test/prebuild/config-node.test.js ./test/prebuild/site-listings.test.js; pnpm run netlify-build
Not-tested: live production deploy before push
2026-04-06 10:51:49 -05:00
ThatGuySam
820e495d2d Keep redirect lookups from crashing SSR pages
Dynamic Astro routes were reading Netlify redirect config through a cwd-relative path, which is fragile inside a serverless runtime and was taking detail pages down with 500s before render.

Resolve netlify.toml by searching from the module directory and current working directory, and fail open in request-time redirect lookup so a config read problem does not block page rendering.

Constraint: Netlify serverless cwd is not guaranteed to be the repo root
Rejected: Inline redirects into route modules | would duplicate platform config and drift from source of truth
Rejected: Leave redirect lookup hard-failing | one config read failure should not take down unrelated pages
Confidence: medium
Scope-risk: narrow
Reversibility: clean
Directive: Keep redirect config lookup independent of process cwd anywhere server code reads deploy config files
Tested: vitest ./test/prebuild/config-node.test.js; pnpm run netlify-build
Not-tested: live Netlify production deploy before push
2026-04-06 10:31:57 -05:00
35 changed files with 1135 additions and 277 deletions

3
.gitignore vendored
View file

@ -102,3 +102,6 @@ dist
/.vscode/snipsnap.code-snippets
.vercel
.env*.local
# Keep plan/search artifacts for OMX workflows
docs/data/

7
AGENTS.md Normal file
View file

@ -0,0 +1,7 @@
# AGENTS Instructions
## Development Model
- Use trunk-based development.
- Make changes directly on `master`.
- Do not create or rely on long-lived feature branches.

View file

@ -59,10 +59,10 @@
<script>
import axios from 'axios'
import { v4 as uuid } from 'uuid'
import { isNuxt } from '~/helpers/environment.js'
import { postJson } from '~/helpers/http.js'
export default {
props: {
@ -142,31 +142,16 @@ export default {
console.log('actionUrl', actionUrl)
axios({
method: 'post',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
url: actionUrl,
data: {
try {
await postJson( actionUrl, {
// Email
'email': this.email
},
}).then( response => {
} )
this.feedbackMessage = 'We\'ll keep you informed!'
// console.log('response', response)
// if (response.status === 200) {
// this.feedbackMessage = '- We\'ll keep an eye on it for you!'
// } else {
// this.feedbackMessage = 'Oops! Something went wrong'
// }
}).catch( error => {
} catch ( error ) {
console.warn('error', error)
this.feedbackMessage = 'Something went wrong. Try refreshing. '
})
}
// .catch(error => {
// // handle error

View file

@ -59,8 +59,6 @@
<script>
import axios from 'axios'
export default {
props: {
appName: {
@ -121,31 +119,16 @@ export default {
const formActionUrl = `https://docs.google.com/forms/d/e/1FAIpQLSdWUAVabT3i1ExfnPgRKnk-s-aWLlOuy0d5JjMKDwKtrwXj1Q/formResponse?entry.710297191=${this.appName}&emailAddress=${this.email}&submit=Submit`
axios({
method: 'get',
url: formActionUrl,
// data: {
// // Email
// 'emailAddress': this.email,
// // App Name
// 'entry.710297191': this.appName,
// // Notes
// // 'entry.2040856090': '',
// 'submit': 'Submit'
// },
}).finally( response => {
this.feedbackMessage = 'We\'ll keep an eye on it for you!'
// console.log('response', response)
// if (response.status === 200) {
// this.feedbackMessage = '- We\'ll keep an eye on it for you!'
// } else {
// this.feedbackMessage = 'Oops! Something went wrong'
// }
try {
await fetch( formActionUrl, {
method: 'GET',
mode: 'no-cors'
} )
} catch ( error ) {
console.warn( 'Error Subscribing -', error )
} finally {
this.feedbackMessage = 'We\'ll keep an eye on it for you!'
}
// .catch(error => {
// // handle error

271
docs/plans/axios-removal.md Normal file
View file

@ -0,0 +1,271 @@
# Original Prompt
> How much refactoring would it be to remove axios?
>
> Make a plan file for this.
# Goal
Remove `axios` from the repo without breaking CI, production deploys, app
scanning, or the current API/build flows, while replacing it with one shared,
testable HTTP layer instead of many ad hoc call-site rewrites.
## 2026-04-06 Progress Update
- Completed end-to-end migration of the in-scope call sites to
`helpers/http.js`.
- Added and tested shared HTTP helper:
- `helpers/http.js` (new)
- `test/prebuild/http.test.ts` (new)
- `package.json` no longer lists `axios`.
- Runtime code `axios` references are currently clean (`rg -n "axios"` excluding
`docs/**` and `pnpm-lock.yaml` returns no hits).
- Validation completed in this pass:
- `pnpm run -s typecheck`
- `pnpm -s run test-prebuild`
- `pnpm -s run test`
- `pnpm -s run test:browser`
- `pnpm -s run lint` ❌ (existing repo-wide lint issues unrelated to axios migration)
- Remaining to close out final gate from this plan:
- execute `pnpm run netlify-build`
- run live deploy verification (`apple-silicon-app-test` URLs) ✅
- confirm whether `pnpm-lock.yaml` changes are sufficient for your lockfile policy
- ✅ `package.json` no longer lists axios
- ✅ `pnpm-lock.yaml` still contains only transitive `gaxios` (Google API client dependency), no direct `axios` entry
# Non-Goals
- Replace every network call with raw `fetch` inline at each call site.
- Change product behavior just to simplify the transport layer.
- Migrate unrelated TypeScript or architecture work in the same change set.
- Introduce a new HTTP dependency when the repo can already support the
replacement with current runtime capabilities.
# Repo Findings
- `axios` is referenced in `19` files across the repo.
- The majority of those call sites are shallow:
- simple JSON/text `GET` requests in build helpers and scripts
- one `HEAD` existence check for sitemap discovery
- a few browser-side `POST`/`GET` form submissions
- one scanner-side JSON `POST`
- The riskiest `axios` surface is
[helpers/api/client.js](/Users/athena/Code/doesitarm/helpers/api/client.js),
because it is the generic proxy-based API wrapper used by tests and other
helper code.
- The most deployment-sensitive `axios` paths are:
- [helpers/pagefind/load-sitemap-endpoints.ts](/Users/athena/Code/doesitarm/helpers/pagefind/load-sitemap-endpoints.ts)
- [scripts/build-pagefind-index.js](/Users/athena/Code/doesitarm/scripts/build-pagefind-index.js)
- [helpers/api/sitemap/parse.js](/Users/athena/Code/doesitarm/helpers/api/sitemap/parse.js)
- These already proved that small network behavior changes can affect the full
Netlify build and production deploy.
- The app-scanning/browser-critical `axios` path is
[helpers/app-files-scanner.js](/Users/athena/Code/doesitarm/helpers/app-files-scanner.js),
where scan submission posts to `TEST_RESULT_STORE`.
- Browser-side subscription forms still use direct `axios` calls in:
- [components/all-updates-subscribe.vue](/Users/athena/Code/doesitarm/components/all-updates-subscribe.vue)
- [components/email-subscribe.vue](/Users/athena/Code/doesitarm/components/email-subscribe.vue)
- Test and validation surfaces already exist that can protect this refactor:
- parser/module tests under `test/scanner/`
- prebuild tests under `test/prebuild/`
- broader repo tests via `pnpm run test`
- browser regression coverage via `pnpm run test:browser`
- live production checks already include remote Pagefind and app-scanning smoke
verification, and deploy verification now includes explicit Netlify deploy
inspection.
- The repo already depends on `ofetch`, but it is not used anywhere today.
- Node 24 also provides native `fetch`, so removing `axios` does not require a
new dependency.
# Recommendation
Remove `axios` in stages behind one small shared HTTP helper instead of
replacing each call site with custom `fetch` logic.
Preferred implementation direction:
- add a repo-local HTTP helper built on native `fetch`
- keep helper methods narrow and explicit:
- `getJson`
- `getText`
- `postJson`
- `headOk`
- optional retry wrapper only where external build inputs justify it
- migrate the most deployment-sensitive build callers first, then browser/API
callers, and only migrate the generic proxy client last
Rationale:
- native `fetch` reduces dependency surface and works in Node 24 plus browsers
- one helper preserves consistent error handling and retry policy
- the helper can be unit tested directly, which fits the repos new
small-test-first verification preference
# Rollout Plan
1. Add the shared HTTP helper and direct unit coverage. ✅
- Create a small helper module under `helpers/` that wraps native `fetch`. ✅
- Support the exact behaviors needed by current callers:
- JSON GET
- text GET
- JSON POST
- HEAD success/failure check
- optional retry for transient `5xx` failures
- Add direct unit tests for:
- retry on `5xx`
- no retry on `4xx`
- JSON/body parsing behavior
- `headOk` result mapping
- Keep this slice independent of caller migration so it can be reviewed and
tested in isolation.
2. Migrate deploy- and build-sensitive callers first. ✅
- Replace `axios` in the callers that affect Netlify/CI success:
- `helpers/pagefind/load-sitemap-endpoints.ts`
- `scripts/build-pagefind-index.js`
- `scripts/download-sitemaps.js`
- `helpers/api/sitemap/parse.js`
- `helpers/api/static.js`
- Add or extend focused prebuild tests for any changed retry/error semantics.
- Verify with `pnpm run test-prebuild` before broader test runs. ✅
3. Migrate data-fetching build helpers. ✅
- Replace `axios` in:
- `helpers/build-app-list.js`
- `helpers/build-homebrew-list.js`
- `helpers/build-game-list.js`
- `helpers/build-device-list.js`
- `helpers/api/youtube/build.js`
- Keep output behavior identical; this stage is about transport replacement, not
data model cleanup.
- Validate with:
- `pnpm run test`
- `pnpm netlify-build`
4. Migrate browser-side form and scanner callers. ✅
- Replace `axios` in:
- `helpers/app-files-scanner.js`
- `components/all-updates-subscribe.vue`
- `components/email-subscribe.vue`
- Preserve current UX semantics:
- success/failure messages
- request payload shapes
- request methods
- Re-run:
- `pnpm run test:browser`
- production app-scanning smoke against both app-test routes ✅
5. Migrate the generic API proxy wrapper last. ✅
- Replace `axios` in
[helpers/api/client.js](/Users/athena/Code/doesitarm/helpers/api/client.js)
only after the lower-risk callers are green. ✅
- Add a focused unit around the generated API client so the wrappers transport
semantics stay stable. ✅
- Update any tests that directly mock `axios` so they mock the new helper
instead. ✅
6. Remove `axios` from the repo.
- Remove `axios` from
[package.json](/Users/athena/Code/doesitarm/package.json). ✅
- Do a final grep to confirm no runtime code imports remain. ✅
- Re-run the full repo validation and live deploy verification. ✅
# Execution Order For This Pass
1. Add `helpers/http.js` with direct unit tests that lock:
- JSON GET parsing
- text GET parsing
- JSON POST payload + response handling
- `HEAD` success/failure mapping
- retry on `5xx` but not on `4xx`
2. Migrate the already-tested sitemap/pagefind caller path first.
- `helpers/pagefind/load-sitemap-endpoints.ts`
- `test/prebuild/load-sitemap-endpoints.test.ts`
3. Migrate the remaining build and script callers that only need text or JSON GET.
- `scripts/download-sitemaps.js`
- `helpers/api/static.js`
- `helpers/api/sitemap/parse.js`
- `helpers/build-*.js`
- `helpers/api/youtube/build.js`
- `scripts/scan-new-apps.js`
- `scripts/vercel-post-deploy/index.js`
4. Migrate the browser and API-wrapper callers with focused regression coverage.
- `helpers/app-files-scanner.js`
- `components/all-updates-subscribe.vue`
- `components/email-subscribe.vue`
- `helpers/api/client.js`
- `test/listings/index.test.ts`
5. Remove `axios` from dependency and lockfile after grep is clean.
# Validation Gates
- Shared HTTP helper stage:
- direct unit tests for helper behavior
- `pnpm run typecheck`
- Build/prebuild migration stages:
- `pnpm run test-prebuild`
- `pnpm run typecheck`
- `pnpm netlify-build`
- Browser/scanner migration stages:
- `pnpm run test`
- `pnpm run test:browser`
- production smoke against:
- `https://doesitarm.com/apple-silicon-app-test/`
- `https://doesitarm.com/apple-silicon-app-test/?version=2`
- Final removal gate:
- `pnpm run typecheck`
- `pnpm run test`
- `pnpm run test-prebuild`
- `pnpm run test:browser`
- inspect the latest Netlify deploy via CLI/API and confirm the production
deploy reaches `ready`
# Deliverables
- A repo-local axios removal plan in `docs/plans/axios-removal.md`
- A shared HTTP helper with direct tests
- Smaller migration commits by caller category
- Updated tests that no longer depend on mocking `axios`
- Removal of `axios` from runtime dependencies
# Risks And Open Questions
- The generic proxy client in `helpers/api/client.js` may hide assumptions about
request config and returned error shape.
- Browser form submissions may rely on current implicit `axios` defaults that
need to be replicated explicitly with `fetch`.
- Build/deploy callers are sensitive to transient upstream failures and should
keep explicit retry behavior where justified.
- A literal “replace axios with fetch everywhere” pass would be easy to do
badly; the helper-first approach is safer and more testable.
- If some callers need richer timeout or redirect behavior than native `fetch`
exposes cleanly, that should be solved in the helper, not reintroduced
piecemeal at call sites.
# Sources
- `package.json`
- `helpers/api/client.js`
- `helpers/api/static.js`
- `helpers/api/sitemap/parse.js`
- `helpers/pagefind/load-sitemap-endpoints.ts`
- `helpers/app-files-scanner.js`
- `helpers/build-app-list.js`
- `helpers/build-homebrew-list.js`
- `helpers/build-game-list.js`
- `helpers/build-device-list.js`
- `helpers/api/youtube/build.js`
- `components/all-updates-subscribe.vue`
- `components/email-subscribe.vue`
- `scripts/build-pagefind-index.js`
- `scripts/download-sitemaps.js`
- `scripts/scan-new-apps.js`
- `test/listings/index.test.ts`
- `test/prebuild/load-sitemap-endpoints.test.ts`

View file

@ -12,9 +12,8 @@
// GET /api/tiles/public/static/3/4/2.json?turn=37038&games=wot
// DoesItAPI.tiles.public.static(3)(4)(`${2}.json`).get({ turn: 37, games: 'wot' })
import axios from 'axios'
import { getApiUrl } from '~/helpers/url.js'
import { requestJson } from '~/helpers/http.js'
// Use msw
import '~/test/msw/use.js'
@ -22,10 +21,12 @@ import '~/test/msw/use.js'
// const defaultFetchMethod = (...args) => console.log(...args) // mock
const defaultFetchMethod = async function (...args) {
return axios(...args)
.then( response => response.data )
return requestJson(...args)
.catch( error => {
if ( error?.response?.status !== 404 ) {
console.error( error )
}
throw error
})
}

View file

@ -1,6 +1,5 @@
import path from 'path'
import fs from 'fs-extra'
import axios from 'axios'
import { parse } from 'fast-xml-parser'
import {
@ -8,6 +7,10 @@ import {
sitemapIndexFileName,
} from '~/helpers/constants.js'
import { isValidHttpUrl } from '~/helpers/check-types.js'
import {
getText,
headOk
} from '~/helpers/http.js'
const sitemapFilesToTry = [
sitemapIndexFileName,
@ -106,12 +109,7 @@ export async function fetchAllUrlsFromSitemaps ( urlString ) {
// console.log( 'sitemapUrl', sitemapUrl.href )
// Just do a quich HEAD request to see if the file exists with getting the whole body
const exists = await axios.head( sitemapUrl.href )
.catch( () => false )
.then( response => {
// console.log( 'response', response.status )
return response.status < 300
} )
const exists = await headOk( sitemapUrl.href )
// console.log( 'exists', exists )
@ -123,8 +121,7 @@ export async function fetchAllUrlsFromSitemaps ( urlString ) {
getMethod: async sitemapPath => {
const sitemapUrl = new URL( sitemapPath, urlString )
const sitemapXml = await axios.get( sitemapUrl.href )
.then( response => response.data )
const sitemapXml = await getText( sitemapUrl.href )
return sitemapXml
}

View file

@ -1,5 +1,4 @@
import fs from 'fs-extra'
import axios from 'axios'
import 'dotenv/config.js'
import {
@ -8,6 +7,7 @@ import {
// storkExecutablePath,
storkTomlPath,
} from '~/helpers/stork/config.js'
import { getText } from '~/helpers/http.js'
export async function downloadStorkToml () {
// Check if the toml file exists
@ -20,12 +20,9 @@ export async function downloadStorkToml () {
apiUrl.pathname = storkTomlPath.replace('static/', '')
const response = await axios({
method: "get",
url: apiUrl.toString(),
})
const storkToml = await getText( apiUrl.toString() )
await fs.writeFile( storkTomlPath, response.data, { encoding: null })
await fs.writeFile( storkTomlPath, storkToml, { encoding: null })
// Get toml file stats
const stats = await fs.stat( storkTomlPath )

View file

@ -1,8 +1,8 @@
import fs from 'fs-extra'
import { google } from 'googleapis'
import axios from 'axios'
import { playlists, benchmarksPlaylistId } from './playlists.js'
import { getJson } from '~/helpers/http.js'
export const youtubeVideoPath = './static/api/youtube-videos.json'
@ -167,8 +167,7 @@ export async function saveYouTubeVideos () {
// const youtubeVideos = await getYouTubeVideos()
// Locked previously sucessful YouTube API data for now
const youtubeVideos = await axios( process.env.VIDEO_SOURCE )
.then( response => response.data )
const youtubeVideos = await getJson( process.env.VIDEO_SOURCE )
// Save to JSON

View file

@ -1,9 +1,9 @@
import plist from 'plist'
import axios from 'axios'
import prettyBytes from 'pretty-bytes'
import * as zip from '@zip.js/zip.js'
import { isString } from './check-types.js'
import { postJson } from './http.js'
import parseMacho from './macho/index.js'
// Vite Web Workers - https://vitejs.dev/guide/features.html#web-workers
@ -331,14 +331,13 @@ export default class AppFilesScanner {
// console.log( 'this.testResultStore', this.testResultStore )
const { supportedVersionNumber } = await axios.post( this.testResultStore , {
const responseData = await postJson( this.testResultStore, {
filename,
appVersion,
result,
machoMeta: JSON.stringify( machoMeta ),
infoPlist: JSON.stringify( infoPlist )
} )
.then( response => response.data )
.catch(function (error) {
console.error(error)
@ -348,7 +347,7 @@ export default class AppFilesScanner {
})
return {
supportedVersionNumber
supportedVersionNumber: responseData?.supportedVersionNumber ?? null
}
}

View file

@ -56,7 +56,13 @@ export async function applyResponseDefaults ( Astro ) {
export async function catchRedirectResponse ( Astro ) {
const requestUrl = new URL( Astro.request.url )
const netlifyRedirectUrl = await getNetlifyRedirect( requestUrl.pathname )
let netlifyRedirectUrl = null
try {
netlifyRedirectUrl = await getNetlifyRedirect( requestUrl.pathname )
} catch ( error ) {
console.warn( `Skipping redirect lookup for ${ requestUrl.pathname }`, error )
}
// console.log('netlifyRedirectUrl', netlifyRedirectUrl)
@ -67,4 +73,3 @@ export async function catchRedirectResponse ( Astro ) {
return null
}

View file

@ -1,6 +1,5 @@
import fs from 'fs-extra'
import MarkdownIt from 'markdown-it'
import axios from 'axios'
import statuses, { getStatusName } from './statuses.js'
import appStoreGenres from './app-store/genres.js'
@ -14,6 +13,7 @@ import { byTimeThenNull } from './sort-list.js'
import {
cliOptions
} from '~/helpers/cli-options.js'
import { getJson } from './http.js'
const md = new MarkdownIt()
@ -277,9 +277,9 @@ const lookForLastUpdated = function (app, commits) {
async function fetchBundleGenres () {
const genresJsonUrl = `${process.env.VFUNCTIONS_URL}/app-store/listings-sheet?fields=bundleId,genreIds`
return await axios.get( genresJsonUrl )
.then( response => {
return new Map( response.data.apps )
return await getJson( genresJsonUrl )
.then( data => {
return new Map( data.apps )
})
.catch(function (error) {
// handle error
@ -315,9 +315,9 @@ export default async function () {
// console.log('readmeContent', readmeContent)
// Fetch Commits
const response = await axios.get(process.env.COMMITS_SOURCE)
const response = await getJson( process.env.COMMITS_SOURCE )
// Extract commit from response data
const commits = response.data.data.viewer.repository.defaultBranchRef.target.history.edges
const commits = response.data.viewer.repository.defaultBranchRef.target.history.edges
// console.log('commits', commits)
// Save commits to file just in case
@ -328,13 +328,11 @@ export default async function () {
const scanListMap = new Map()
// Store app scans
await axios
.get(process.env.SCANS_SOURCE)
await getJson( process.env.SCANS_SOURCE )
.then( async response => {
const appBundles = []
for (const appScan of response.data.appList) {
for (const appScan of response.appList) {
// Add app to bundle list
appBundles.push([
@ -349,11 +347,7 @@ export default async function () {
await fs.writeJson('./static/app-bundles.json', appBundles)
return response
})
.then(function (response) {
response.data.appList.forEach( appScan => {
response.appList.forEach( appScan => {
const appName = appScan.aliases[0]
@ -424,8 +418,6 @@ export default async function () {
relatedLinks
})
})
return
})
.catch(function (error) {
// handle error

View file

@ -1,6 +1,5 @@
import axios from 'axios'
import { makeSlug } from './slug.js'
import { getJson } from './http.js'
export function getDeviceEndpoint ( slug ) {
return `/device/${ slug }`
@ -12,10 +11,7 @@ export default async function () {
const devicesJsonUrl = `${process.env.VFUNCTIONS_URL}/api/devices`
const rawDeviceList = await axios.get(devicesJsonUrl)
.then( response => {
return response.data
})
const rawDeviceList = await getJson( devicesJsonUrl )
.catch(function (error) {
// handle error
console.warn('Error fetching device list', error)

View file

@ -1,8 +1,7 @@
import axios from 'axios'
// import { statuses } from './build-app-list'
import { getAppEndpoint } from './app-derived'
import { makeSlug } from './slug.js'
import { getJson } from './http.js'
// console.log('process.env.GAMES_SOURCE', process.env.GAMES_SOURCE)
@ -68,11 +67,10 @@ function parseStatus(game) {
export default async function () {
// Fetch Sheet data
const gamesSheet = await axios
.get(process.env.GAMES_SOURCE)
const gamesSheet = await getJson( process.env.GAMES_SOURCE )
.then(function (response) {
// handle success
return response.data.records
return response.records
})
.catch(function (error) {
// handle error

View file

@ -2,7 +2,6 @@
// import { promises as fs } from 'fs'
// import MarkdownIt from 'markdown-it'
// import slugify from 'slugify'
import axios from 'axios'
// import statuses from './statuses'
// import parseDate from './parse-github-date'
@ -11,6 +10,7 @@ const marked = require('marked')
const HTMLParser = require(`node-html-parser`)
import { getAppEndpoint } from './app-derived'
import { getJson } from './http.js'
const statusesTranslations = {
@ -117,13 +117,13 @@ class MakeHomebrewList {
allFormulaeResponse
] = await Promise.all([
// Fetch Gihub Issue List
axios.get(process.env.HOMEBREW_SOURCE),
getJson( process.env.HOMEBREW_SOURCE ),
// Fetch Official Homebrew Formulae List
axios.get('https://formulae.brew.sh/api/formula.json')
getJson( 'https://formulae.brew.sh/api/formula.json' )
])
// Extract commit from response data
const issueMarkdown = issueResponse.data.data.repository.issue.body
const issueMarkdown = issueResponse.data.repository.issue.body
// Parse markdown
const issueHTML = marked(issueMarkdown)
@ -132,10 +132,10 @@ class MakeHomebrewList {
const dom = HTMLParser.parse(issueHTML)
// Store the original array
this.allFormulaeArray = allFormulaeResponse.data
this.allFormulaeArray = allFormulaeResponse
// Extract list from allFormulaeResponse and map into an object for easy access
this.allFormulae = Object.fromEntries(allFormulaeResponse.data.map(formula => {
this.allFormulae = Object.fromEntries(allFormulaeResponse.map(formula => {
return [
formula.full_name,
formula

View file

@ -33,6 +33,10 @@ const videoFeaturesApp = function (app, video) {
return false
}
export function makeVideoSlug ( title, videoId ) {
return makeSlug( `${ title }-i-${ videoId }` )
}
const generateVideoTags = function ( video ) {
const tags = {
'benchmark': {
@ -129,7 +133,7 @@ const makeThumbnailData = function ( thumbnails, widthLimit = null ) {
}
}
async function handleFetchedVideo ( fetchedVideo, videoId, applist ) {
export async function buildVideoListingFromFetchedVideo ( fetchedVideo, videoId, applist ) {
// Skip private videos
if (fetchedVideo.title === 'Private video') return
@ -138,7 +142,7 @@ async function handleFetchedVideo ( fetchedVideo, videoId, applist ) {
if (fetchedVideo.title === 'Deleted video') return
// Build video slug
const slug = makeSlug( `${fetchedVideo.title}-i-${videoId}` )
const slug = makeVideoSlug( fetchedVideo.title, videoId )
const appLinks = []
// Generate new tag set based on api data
@ -190,7 +194,7 @@ export default async function ( applist ) {
// const videosJsonUrl = process.env.VIDEO_SOURCE || `${process.env.VFUNCTIONS_URL}/videos.json`
// Fetch Commits
// const response = await axios.get( videosJsonUrl )
// const response = await fetch( videosJsonUrl )
// Extract commit from response data
const fetchedVideos = await fs.readJson( youtubeVideoPath )//response.data
@ -200,8 +204,7 @@ export default async function ( applist ) {
.withConcurrency(1000)
.for( Object.entries( fetchedVideos ) )
.process(async ( [ videoId, fetchedVideo ], index, pool ) => {
const mappedVideo = await handleFetchedVideo ( fetchedVideo, videoId, applist )
const mappedVideo = await buildVideoListingFromFetchedVideo( fetchedVideo, videoId, applist )
// Skip if this video is not an object
if ( Object( mappedVideo ) !== mappedVideo ) return

View file

@ -1,5 +1,7 @@
import TOML from '@iarna/toml'
import fs from 'fs-extra'
import path from 'path'
import { fileURLToPath } from 'url'
import pkg from '~/package.json'
import { publicRuntimeConfig } from '~/helpers/public-runtime-config.mjs'
@ -8,6 +10,7 @@ import { getRouteType } from '~/helpers/app-derived.js'
export const siteUrl = getSiteUrl()
const currentModuleDirectory = path.dirname( fileURLToPath( import.meta.url ) )
export const nuxtHead = {
// this htmlAttrs you need
@ -113,8 +116,41 @@ export const nuxtHead = {
export async function getNetlifyConfigPath () {
const searchDirectories = new Set()
// Local dev usually runs from repo root, but deployed serverless
// functions may execute from a nested working directory.
for ( const baseDirectory of [
process.cwd(),
currentModuleDirectory,
] ) {
let directory = baseDirectory
while ( true ) {
searchDirectories.add( directory )
const parentDirectory = path.dirname( directory )
if ( parentDirectory === directory ) break
directory = parentDirectory
}
}
for ( const directory of searchDirectories ) {
const configPath = path.join( directory, 'netlify.toml' )
if ( await fs.pathExists( configPath ) ) {
return configPath
}
}
throw new Error( 'Could not find netlify.toml' )
}
export async function getNetlifyConfig () {
const configPath = './netlify.toml'
const configPath = await getNetlifyConfigPath()
const tomlContent = await fs.readFile(configPath, 'utf-8')
const netlifyConfig = TOML.parse(tomlContent)

244
helpers/http.js Normal file
View file

@ -0,0 +1,244 @@
function sleep ( delayMs ) {
return new Promise( resolve => setTimeout( resolve, delayMs ) )
}
function normalizeUrl ( url ) {
if ( url instanceof URL ) {
return url.toString()
}
return String( url )
}
function toRequestConfig ( input, options = {} ) {
if ( typeof input === 'string' || input instanceof URL ) {
return {
...options,
url: normalizeUrl( input )
}
}
if ( input && typeof input === 'object' && 'url' in input ) {
return {
...input,
...options,
url: normalizeUrl( input.url )
}
}
throw new Error( 'Expected a request URL or config object with a url field.' )
}
function createHeaders ( inputHeaders = {} ) {
return new Headers( inputHeaders )
}
function hasResponseStatus ( error ) {
return typeof error?.response?.status === 'number'
}
export function shouldRetryError ( error ) {
return hasResponseStatus( error ) && error.response.status >= 500
}
export class HttpError extends Error {
constructor ( message, {
cause,
data = null,
method,
status,
statusText,
url
} ) {
super( message )
this.name = 'HttpError'
this.cause = cause
this.method = method
this.status = status
this.url = url
this.response = {
data,
status,
statusText,
url
}
}
}
async function parseResponseBody ( response, responseType ) {
if ( responseType === 'none' ) {
return null
}
if ( responseType === 'text' ) {
return await response.text()
}
const text = await response.text()
if ( text.length === 0 ) {
return null
}
return JSON.parse( text )
}
function buildRequestInit ( {
body,
cache,
credentials,
headers,
method,
mode,
redirect,
signal
} ) {
return {
body,
cache,
credentials,
headers,
method,
mode,
redirect,
signal
}
}
export async function request ( input, options = {} ) {
const config = toRequestConfig( input, options )
const {
attempts = 1,
cache,
credentials,
data,
delayMs = 1000,
headers: inputHeaders,
method: inputMethod = 'GET',
mode,
redirect,
responseType = 'json',
signal,
url
} = config
const method = inputMethod.toUpperCase()
const headers = createHeaders( inputHeaders )
let body
if ( data !== undefined ) {
body = JSON.stringify( data )
if ( !headers.has( 'Accept' ) ) {
headers.set( 'Accept', 'application/json' )
}
if ( !headers.has( 'Content-Type' ) ) {
headers.set( 'Content-Type', 'application/json' )
}
}
let lastError
for ( let attempt = 1; attempt <= attempts; attempt += 1 ) {
try {
const response = await fetch( url, buildRequestInit({
body,
cache,
credentials,
headers,
method,
mode,
redirect,
signal
}) )
const responseData = await parseResponseBody( response, responseType )
if ( !response.ok ) {
throw new HttpError(
`${ method } ${ url } failed with status ${ response.status }`,
{
data: responseData,
method,
status: response.status,
statusText: response.statusText,
url
}
)
}
return {
data: responseData,
response
}
} catch ( error ) {
lastError = error
if ( attempt >= attempts || !shouldRetryError( error ) ) {
throw error
}
await sleep( delayMs )
}
}
throw lastError
}
export async function getJson ( url, options = {} ) {
const { data } = await request( url, {
...options,
method: 'GET',
responseType: 'json'
} )
return data
}
export async function getText ( url, options = {} ) {
const { data } = await request( url, {
...options,
method: 'GET',
responseType: 'text'
} )
return data
}
export async function postJson ( url, data, options = {} ) {
const { data: responseData } = await request( url, {
...options,
data,
method: 'POST',
responseType: 'json'
} )
return responseData
}
export async function requestJson ( input, options = {} ) {
const { data } = await request( input, {
...options,
responseType: 'json'
} )
return data
}
export async function headOk ( url, options = {} ) {
try {
await request( url, {
...options,
method: 'HEAD',
responseType: 'none'
} )
return true
} catch ( error ) {
if ( error instanceof Error ) {
return false
}
throw error
}
}

View file

@ -1,16 +1,13 @@
import fs from 'fs-extra'
import axios from 'axios'
import {
getJson,
shouldRetryError
} from '~/helpers/http.js'
import {
sitemapEndpointsPath
} from '~/helpers/pagefind/config.js'
function shouldRetryError ( error: unknown ) {
const status = ( error as { response?: { status?: number } } )?.response?.status
return typeof status === 'number' && status >= 500
}
async function fetchJsonWithRetries (
url: string,
{
@ -21,25 +18,10 @@ async function fetchJsonWithRetries (
delayMs?: number
} = {}
) {
let lastError: unknown
for ( let attempt = 1; attempt <= attempts; attempt += 1 ) {
try {
const response = await axios.get( url )
return response.data
} catch ( error ) {
lastError = error
if ( attempt >= attempts || !shouldRetryError( error ) ) {
throw error
}
await new Promise( resolve => setTimeout( resolve, delayMs ) )
}
}
throw lastError
return await getJson( url, {
attempts,
delayMs
} )
}
export async function loadSitemapEndpoints () {

37
helpers/site-listings.js Normal file
View file

@ -0,0 +1,37 @@
import youtubeVideosText from '~/static/api/youtube-videos.json?raw'
import appListText from '~/static/app-list.json?raw'
import deviceListText from '~/static/device-list.json?raw'
import gameListText from '~/static/game-list.json?raw'
import {
buildVideoListingFromFetchedVideo,
makeVideoSlug
} from '~/helpers/build-video-list.js'
const trailingCommaPattern = /,\s*([\]}])/g
const parsedDeviceList = JSON.parse( deviceListText.replace( trailingCommaPattern, '$1' ) )
const parsedAppList = JSON.parse( appListText.replace( trailingCommaPattern, '$1' ) )
const parsedGameList = JSON.parse( gameListText.replace( trailingCommaPattern, '$1' ) )
const parsedYoutubeVideos = JSON.parse( youtubeVideosText )
export function getDeviceListingBySlug ( slug ) {
return parsedDeviceList.find( device => device.slug === slug ) || null
}
function getAllVideoAppsList () {
return [
...parsedAppList,
...parsedGameList
]
}
export async function getVideoListingBySlug ( slug ) {
const allVideoAppsList = getAllVideoAppsList()
for ( const [ videoId, fetchedVideo ] of Object.entries( parsedYoutubeVideos ) ) {
if ( makeVideoSlug( fetchedVideo.title, videoId ) !== slug ) continue
return await buildVideoListingFromFetchedVideo( fetchedVideo, videoId, allVideoAppsList )
}
return null
}

View file

@ -82,7 +82,6 @@
"@supercharge/promise-pool": "^2.1.0",
"@zip.js/zip.js": "^2.5.25",
"astro": "^6.0.4",
"axios": "^0.21.0",
"buffer": "^6.0.3",
"can-autoplay": "^3.0.0",
"chance": "^1.1.7",

View file

@ -169,7 +169,6 @@
</template>
<script>
// import axios from 'axios'
import AppFilesScanner from '~/helpers/app-files-scanner.js'

24
pnpm-lock.yaml generated
View file

@ -47,9 +47,6 @@ importers:
astro:
specifier: ^6.0.4
version: 6.0.4(@netlify/blobs@10.7.2)(@types/node@24.12.0)(jiti@2.6.1)(rollup@4.59.0)(terser@5.46.0)(typescript@5.7.3)(yaml@2.8.2)
axios:
specifier: ^0.21.0
version: 0.21.0
buffer:
specifier: ^6.0.3
version: 6.0.3
@ -2152,10 +2149,6 @@ packages:
aws4@1.12.0:
resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==}
axios@0.21.0:
resolution: {integrity: sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==}
deprecated: Critical security vulnerability fixed in v0.21.1. For more information, see https://github.com/axios/axios/pull/3410
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@ -3799,15 +3792,6 @@ packages:
fn.name@1.1.0:
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
follow-redirects@1.15.2:
resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
fontace@0.4.1:
resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==}
@ -10254,12 +10238,6 @@ snapshots:
aws4@1.12.0: {}
axios@0.21.0:
dependencies:
follow-redirects: 1.15.2
transitivePeerDependencies:
- debug
axobject-query@4.1.0: {}
b4a@1.8.0: {}
@ -12147,8 +12125,6 @@ snapshots:
fn.name@1.1.0: {}
follow-redirects@1.15.2: {}
fontace@0.4.1:
dependencies:
fontkitten: 1.0.3

View file

@ -1,6 +1,5 @@
import fs from 'fs-extra'
import 'dotenv/config.js'
import axios from 'axios'
import {
sitemapLocation,
@ -8,6 +7,7 @@ import {
} from '~/helpers/constants.js'
import { parseSitemapXml } from '~/helpers/api/sitemap/parse.js'
import { getText } from '~/helpers/http.js'
;(async () => {
@ -16,7 +16,7 @@ import { parseSitemapXml } from '~/helpers/api/sitemap/parse.js'
const sitemapIndexUrl = new URL( `${ sitemapLocation.split('static')[1] }${ sitemapIndexFileName }`, process.env.PUBLIC_API_DOMAIN )
// Fetch Sitemap Index
const sitemapIndexXML = await axios.get( sitemapIndexUrl.href ).then( response => response.data )
const sitemapIndexXML = await getText( sitemapIndexUrl.href )
// Save Sitemap Index
const sitemapIndexFilePath = `${ sitemapLocation }${ sitemapIndexFileName }`
@ -35,7 +35,7 @@ import { parseSitemapXml } from '~/helpers/api/sitemap/parse.js'
// sitemapUrl.origin = process.env.PUBLIC_API_DOMAIN
// Fetch Sitemap Index
const sitemapXML = await axios.get( apiSitemapUrl.href ).then( response => response.data )
const sitemapXML = await getText( apiSitemapUrl.href )
// const sitemap = parse( sitemapXML )

104
scripts/health Executable file
View file

@ -0,0 +1,104 @@
#!/usr/bin/env bun
const routeGroups = {
topLevel: [
'/',
'/categories',
'/devices',
'/benchmarks',
'/games',
'/apple-silicon-app-test'
],
dynamic: [
'/app/kicad-eda',
'/app/spotify',
'/formula/bash',
'/kind/developer-tools',
'/device/m1-imac',
'/app/expressvpn/benchmarks'
],
video: [
'/tv/apple-silicon-gaming-is-here',
'/tv/install-instagram-app-on-m1-macbook-air-apple-silicon-tutorial-i-vfbmworal6i',
'/tv/xamarin-and-visual-studio-on-apple-macbook-pro-13-m1-in-4k-i-rwpspmmlos',
'/tv/watch-this-before-buying-apple-m1-macbook-for-xampp-or-apple-silicon-tests-in-4k-i-ebwwewsis8s'
]
}
function parseHosts(rawHosts) {
const source = rawHosts && rawHosts.trim().length > 0
? rawHosts
: 'doesitarm.com'
return source
.split(',')
.map(host => host.trim())
.filter(Boolean)
.map(host => host.startsWith('http://') || host.startsWith('https://') ? host : `https://${host}`)
}
function getPaths() {
return Object.values(routeGroups).flat()
}
function extractTitle(html) {
const match = html.match(/<title>([^<]+)<\/title>/i)
return match ? match[1].trim() : ''
}
async function runCheck(host, path) {
const url = new URL(path, host)
const response = await fetch(url, {
redirect: 'follow',
headers: {
'user-agent': 'doesitarm-health-check'
}
})
const html = await response.text()
const finalUrl = response.url
return {
host: new URL(host).host,
path,
status: response.status,
ok: response.ok,
finalPath: new URL(finalUrl).pathname,
title: extractTitle(html)
}
}
const hosts = parseHosts(process.argv[2] || '')
const paths = getPaths()
console.log(`Checking ${paths.length} routes across ${hosts.length} host(s)`)
let hasFailures = false
for (const host of hosts) {
console.log(`\nHost: ${new URL(host).host}`)
const results = await Promise.all(paths.map(path => runCheck(host, path)))
for (const result of results) {
const statusLabel = result.ok ? 'PASS' : 'FAIL'
const redirectSuffix = result.finalPath !== result.path ? ` -> ${result.finalPath}` : ''
const titleSuffix = result.title.length > 0 ? ` | ${result.title}` : ''
console.log(`${statusLabel} ${result.status} ${result.path}${redirectSuffix}${titleSuffix}`)
}
const failures = results.filter(result => !result.ok)
if (failures.length > 0) {
hasFailures = true
console.log(`Failures: ${failures.length}`)
} else {
console.log('Failures: 0')
}
}
if (hasFailures) {
process.exit(1)
}

View file

@ -1,8 +1,8 @@
import { createServer } from 'vite'
import axios from 'axios'
import viteConfig from '~/vite.config.mjs'
import { isLinux } from '~/helpers/environment.js'
import { getText } from '~/helpers/http.js'
const port = 1337
@ -29,12 +29,12 @@ const runScans = false
console.log(`Server listening on https://${ vercelUrl }:${ port }/`)
const { data } = await axios.get(`http://${ vercelUrl }:${ port }/`)
const data = await getText( `http://${ vercelUrl }:${ port }/` )
.catch( err => {
console.log( 'err', err )
})
console.log( data.slice(0, 100) )
console.log( data?.slice(0, 100) )
await server.close();

View file

@ -1,6 +1,6 @@
import { isMacOS } from 'std-env'
import axios from 'axios'
import { getText } from '~/helpers/http.js'
;(async () => {
@ -10,7 +10,7 @@ import axios from 'axios'
process.exit()
}
const { data } = await axios.get(`https://master--doesitarm.netlify.app/apple-silicon-app-test`)
const data = await getText( 'https://master--doesitarm.netlify.app/apple-silicon-app-test' )
console.log( data.slice(0, 100) )

View file

@ -10,6 +10,7 @@ import {
applyResponseDefaults
} from '~/helpers/astro/request.js'
import { deviceSupportsApp } from '~/helpers/devices.js'
import { getDeviceListingBySlug } from '~/helpers/site-listings.js'
import Layout from '../../layouts/default.astro'
@ -36,7 +37,22 @@ if ( redirectResponse !== null ) {
applyResponseDefaults( Astro )
const device = await DoesItAPI.device( pathSlug ).get()
let device
try {
device = await DoesItAPI.device( pathSlug ).get()
} catch ( error ) {
if ( error?.response?.status !== 404 ) {
throw error
}
device = getDeviceListingBySlug( pathSlug )
}
if ( device === null || typeof device === 'undefined' ) {
return Astro.redirect( '/devices' )
}
const rawAppPage = await DoesItAPI.kind( 'app' )( subSlug ).get()

View file

@ -11,6 +11,7 @@ import {
getVideoImages,
ListingDetails
} from '~/helpers/listing-page.js'
import { getVideoListingBySlug } from '~/helpers/site-listings.js'
import { getPathPartsFromAstroRequest } from '~/helpers/url.js'
import Layout from '~/src/layouts/default.astro'
@ -39,7 +40,21 @@ const {
// https://docs.astro.build/en/reference/api-reference/#astrorequests
// Request App data from API
const tvListing = await DoesItAPI.tv( pathSlug ).get()
let tvListing
try {
tvListing = await DoesItAPI.tv( pathSlug ).get()
} catch ( error ) {
if ( error?.response?.status !== 404 ) {
throw error
}
tvListing = await getVideoListingBySlug( pathSlug )
}
if ( tvListing === null || typeof tvListing === 'undefined' ) {
return Astro.redirect( '/benchmarks' )
}
const listingDetails = new ListingDetails( tvListing )

View file

@ -1,7 +1,6 @@
import fs from 'fs-extra'
import has from 'just-has'
import { test, expect, beforeAll } from 'vitest'
import axios from 'axios'
import { JSDOM } from 'jsdom'
import { structuredDataTestHtml } from 'structured-data-testing-tool'
import { Google } from 'structured-data-testing-tool/presets'
@ -11,6 +10,7 @@ import {
getVideoImages,
ListingDetails
} from '~/helpers/listing-page.js'
import { getJson } from '~/helpers/http.js'
import { headPropertyTypes } from '~/test/helpers/head.js'
import { PageHead } from '~/helpers/config-node.js'
@ -73,8 +73,7 @@ beforeAll(async () => {
continue
}
const { data } = await axios.get(`${process.env.PUBLIC_API_DOMAIN}${apiPath}`)
context.listings[caseEndpoint] = data
context.listings[caseEndpoint] = await getJson( `${process.env.PUBLIC_API_DOMAIN}${apiPath}` )
}
// Initialize listing details

View file

@ -0,0 +1,41 @@
import fs from 'fs-extra'
import os from 'os'
import path from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import {
getNetlifyConfigPath,
getNetlifyRedirect
} from '~/helpers/config-node.js'
const originalCwd = process.cwd()
afterEach(() => {
process.chdir( originalCwd )
})
describe( 'netlify config helpers', () => {
it( 'resolves netlify.toml even when cwd is outside the repo root', async () => {
const tempDirectory = await fs.mkdtemp( path.join( os.tmpdir(), 'doesitarm-netlify-' ) )
process.chdir( tempDirectory )
const configPath = await getNetlifyConfigPath()
expect( configPath ).toBe( path.join( originalCwd, 'netlify.toml' ) )
})
it( 'loads redirects when cwd is outside the repo root', async () => {
const tempDirectory = await fs.mkdtemp( path.join( os.tmpdir(), 'doesitarm-netlify-' ) )
process.chdir( tempDirectory )
const redirect = await getNetlifyRedirect( '/app/electron' )
expect( redirect ).toMatchObject({
from: '/app/electron',
to: '/app/electron-framework',
status: 301
})
})
})

170
test/prebuild/http.test.ts Normal file
View file

@ -0,0 +1,170 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest'
import {
getJson,
getText,
headOk,
postJson,
requestJson,
shouldRetryError
} from '~/helpers/http.js'
describe( 'http helper', () => {
const fetchMock = vi.fn()
beforeEach( () => {
vi.stubGlobal( 'fetch', fetchMock )
} )
afterEach( () => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
fetchMock.mockReset()
} )
it( 'retries transient 5xx errors and eventually resolves JSON', async () => {
fetchMock
.mockResolvedValueOnce( new Response( JSON.stringify({
ok: false
}), {
headers: {
'Content-Type': 'application/json'
},
status: 502
} ) )
.mockResolvedValueOnce( new Response( JSON.stringify({
ok: true
}), {
headers: {
'Content-Type': 'application/json'
},
status: 200
} ) )
await expect( getJson( 'https://api.doesitarm.com/sitemap-endpoints.json', {
attempts: 2,
delayMs: 0
} ) ).resolves.toEqual({
ok: true
} )
expect( fetchMock ).toHaveBeenCalledTimes( 2 )
} )
it( 'does not retry non-5xx errors', async () => {
fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({
ok: false
}), {
headers: {
'Content-Type': 'application/json'
},
status: 404
} ) )
await expect( getJson( 'https://api.doesitarm.com/sitemap-endpoints.json', {
attempts: 3,
delayMs: 0
} ) ).rejects.toMatchObject({
response: {
status: 404
}
})
expect( fetchMock ).toHaveBeenCalledTimes( 1 )
} )
it( 'returns text responses', async () => {
fetchMock.mockResolvedValueOnce( new Response( '<xml />', {
headers: {
'Content-Type': 'application/xml'
},
status: 200
} ) )
await expect( getText( 'https://doesitarm.com/sitemap.xml' ) ).resolves.toBe( '<xml />' )
} )
it( 'posts JSON payloads and parses JSON responses', async () => {
fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({
supportedVersionNumber: '2.1.0'
}), {
headers: {
'Content-Type': 'application/json'
},
status: 200
} ) )
await expect( postJson( 'https://doesitarm.com/api/test-results', {
filename: 'App.zip'
} ) ).resolves.toEqual({
supportedVersionNumber: '2.1.0'
} )
expect( fetchMock ).toHaveBeenCalledWith(
'https://doesitarm.com/api/test-results',
expect.objectContaining({
body: JSON.stringify({
filename: 'App.zip'
}),
method: 'POST'
})
)
} )
it( 'supports config-object JSON requests for the API client', async () => {
fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({
ok: true
}), {
headers: {
'Content-Type': 'application/json'
},
status: 200
} ) )
await expect( requestJson({
method: 'GET',
url: 'https://doesitarm.com/api/app/spotify.json'
}) ).resolves.toEqual({
ok: true
} )
} )
it( 'maps head requests to booleans', async () => {
fetchMock
.mockResolvedValueOnce( new Response( null, {
status: 200
} ) )
.mockResolvedValueOnce( new Response( null, {
status: 404
} ) )
await expect( headOk( 'https://doesitarm.com/sitemap.xml' ) ).resolves.toBe( true )
await expect( headOk( 'https://doesitarm.com/missing.xml' ) ).resolves.toBe( false )
} )
it( 'classifies retryable errors by HTTP status', () => {
expect( shouldRetryError( {
response: {
status: 502
}
} ) ).toBe( true )
expect( shouldRetryError( {
response: {
status: 503
}
} ) ).toBe( true )
expect( shouldRetryError( {
response: {
status: 404
}
} ) ).toBe( false )
expect( shouldRetryError( new Error( 'network' ) ) ).toBe( false )
} )
} )

View file

@ -6,89 +6,63 @@ import {
vi
} from 'vitest'
import axios from 'axios'
import fs from 'fs-extra'
import {
fetchJsonWithRetries,
shouldRetryError
loadSitemapEndpoints
} from '~/helpers/pagefind/load-sitemap-endpoints'
import { getJson } from '~/helpers/http.js'
vi.mock( 'axios', () => {
vi.mock( 'fs-extra', () => {
return {
default: {
get: vi.fn()
pathExists: vi.fn(),
readJson: vi.fn()
}
}
} )
describe( 'load sitemap endpoints helper', () => {
vi.mock( '~/helpers/http.js', () => {
return {
getJson: vi.fn(),
shouldRetryError: vi.fn()
}
} )
describe( 'load sitemap endpoints', () => {
beforeEach( () => {
vi.mocked( axios.get ).mockReset()
vi.mocked( fs.pathExists ).mockReset()
vi.mocked( fs.readJson ).mockReset()
vi.mocked( getJson ).mockReset()
} )
it( 'retries transient 5xx errors and eventually resolves', async () => {
const axiosGet = vi.mocked( axios.get )
axiosGet
.mockRejectedValueOnce({
response: {
status: 502
}
})
.mockResolvedValueOnce({
data: {
ok: true
}
it( 'reads the local sitemap-endpoints file when it exists', async () => {
vi.mocked( fs.pathExists ).mockResolvedValueOnce( true )
vi.mocked( fs.readJson ).mockResolvedValueOnce({
endpoints: [ '/api/app/spotify.json' ]
} )
const data = await fetchJsonWithRetries( 'https://api.doesitarm.com/sitemap-endpoints.json', {
attempts: 2,
delayMs: 0
await expect( loadSitemapEndpoints() ).resolves.toEqual({
endpoints: [ '/api/app/spotify.json' ]
})
expect( data ).toEqual({
ok: true
} )
expect( axiosGet ).toHaveBeenCalledTimes( 2 )
expect( getJson ).not.toHaveBeenCalled()
} )
it( 'does not retry non-5xx errors', async () => {
const axiosGet = vi.mocked( axios.get )
it( 'falls back to the remote sitemap-endpoints JSON when the local file is missing', async () => {
vi.mocked( fs.pathExists ).mockResolvedValueOnce( false )
vi.mocked( getJson ).mockResolvedValueOnce({
endpoints: [ '/api/app/electron-framework.json' ]
} )
process.env.PUBLIC_API_DOMAIN = 'https://api.doesitarm.com'
axiosGet.mockRejectedValueOnce({
response: {
status: 404
}
await expect( loadSitemapEndpoints() ).resolves.toEqual({
endpoints: [ '/api/app/electron-framework.json' ]
} )
await expect( fetchJsonWithRetries( 'https://api.doesitarm.com/sitemap-endpoints.json', {
expect( getJson ).toHaveBeenCalledWith( 'https://api.doesitarm.com/sitemap-endpoints.json', {
attempts: 3,
delayMs: 0
} ) ).rejects.toEqual({
response: {
status: 404
}
})
expect( axiosGet ).toHaveBeenCalledTimes( 1 )
} )
it( 'classifies retryable server errors', () => {
expect( shouldRetryError( {
response: {
status: 502
}
} ) ).toBe( true )
expect( shouldRetryError( {
response: {
status: 503
}
} ) ).toBe( true )
expect( shouldRetryError( {
response: {
status: 404
}
} ) ).toBe( false )
expect( shouldRetryError( new Error( 'network' ) ) ).toBe( false )
delayMs: 1000
})
} )
} )

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import {
getDeviceListingBySlug,
getVideoListingBySlug
} from '~/helpers/site-listings.js'
describe( 'site listing fallbacks', () => {
it( 'loads known devices from the bundled device list', () => {
expect( getDeviceListingBySlug( 'm1-imac' ) ).toMatchObject({
name: 'M1 iMac',
endpoint: '/device/m1-imac'
})
})
it( 'rebuilds known tv listings from the bundled YouTube source', async () => {
await expect(
getVideoListingBySlug( 'install-instagram-app-on-m1-macbook-air-apple-silicon-tutorial-i-vfbmworal6i' )
).resolves.toMatchObject({
endpoint: '/tv/install-instagram-app-on-m1-macbook-air-apple-silicon-tutorial-i-vfbmworal6i'
})
})
it( 'returns null for missing tv slugs', async () => {
await expect(
getVideoListingBySlug( 'apple-silicon-gaming-is-here' )
).resolves.toBeNull()
})
})

View file

@ -1,20 +1,16 @@
import { Buffer } from 'buffer'
import {
describe,
expect,
it,
vi
} from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
parseFileSync,
parsePlistBuffer
parsePlistBuffer,
} from '~/helpers/scanner/parsers/plist-parser'
type ParsedPlist = Record<string, string>
const xmlPlist = Buffer.from( [
const xmlPlist = Buffer.from(
[
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
@ -24,13 +20,18 @@ const xmlPlist = Buffer.from( [
' <key>CFBundleIdentifier</key>',
' <string>com.doesitarm.playwright-native-app</string>',
'</dict>',
'</plist>'
].join( '\n' ), 'utf8' )
'</plist>',
].join('\n'),
'utf8',
)
describe('plist parser', () => {
it('parses xml plist buffers asynchronously', async () => {
const callback = vi.fn()
const plist = await parsePlistBuffer( xmlPlist as any, callback ) as ParsedPlist
const plist = (await parsePlistBuffer(
xmlPlist as any,
callback,
)) as ParsedPlist
expect(plist.CFBundleExecutable).toBe('Playwright Native App')
expect(plist.CFBundleIdentifier).toBe('com.doesitarm.playwright-native-app')
@ -45,8 +46,8 @@ describe( 'plist parser', () => {
})
it('rejects invalid plist data', async () => {
await expect( parsePlistBuffer( Buffer.from( 'not-a-plist', 'utf8' ) as any ) )
.rejects
.toThrow( /Invalid binary plist/i )
await expect(
parsePlistBuffer(Buffer.from('not-a-plist', 'utf8') as any),
).rejects.toThrow(/Invalid binary plist/i)
})
})