mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
ci(client): lint:offline stops new views from calling the API directly
The offline core only works when the views go through it: component, feature hook, store, repo, then api or Dexie, with writes through the mutation queue. A view that imports src/api/ skips the cache, the optimistic write and the idempotent replay. client/CLAUDE.md said so and noted that no ratchet held the line, and the number of view files importing api/client grew from 161 to 190 in two months. lint:offline lists the 213 files under components/, mobile/, pages/ and hooks/ that import anything from src/api/ for more than types (api/client, the websocket, the per-domain modules) in scripts/offline-baseline.json. No other view file may, and a listed file that stops leaves the list through --update, so the next file at that path does not inherit the exemption. Type-only imports do not count: they carry no request. client/CLAUDE.md describes the rule next to the data-flow section, including that an online-only domain puts its calls in a store or repo.
This commit is contained in:
+2
-1
@@ -14,6 +14,7 @@ npm run lint:rtl # physical left/right sides and rtl-lint-disable comme
|
||||
npm run lint:size # no source file or stylesheet past 1000 lines, no test past 2000, counted wrapped at 120 columns; longer ones may only shrink (CI gate; --update lowers the baseline)
|
||||
npm run lint:format # Prettier: every file outside scripts/format-baseline.json must be formatted, the list only shrinks (CI gate; npx prettier --write <file>, then --update)
|
||||
npm run lint:layers # imports go downwards: components never import pages/ or mobile/, nothing under the views imports a view; per-file counts may only shrink (CI gate; --list, --update)
|
||||
npm run lint:offline # no view file (components/mobile/pages/hooks) imports src/api/ beyond scripts/offline-baseline.json, which only shrinks (CI gate; --list, --update)
|
||||
npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
|
||||
npm run e2e # Playwright (CI runs --project=public --project=app; e2e:report opens the last report)
|
||||
npm run shots # Playwright screenshot project (shots:promote to accept)
|
||||
@@ -43,7 +44,7 @@ The layering is **component → feature hook → store/slice → `repo/` → `ap
|
||||
|
||||
The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts:
|
||||
|
||||
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal — especially never *write* from one. A long tail of existing files bypasses the layering and no lint ratchet holds the line yet; that is debt, not precedent.
|
||||
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal — especially never *write* from one. `lint:offline` enforces it: a file under `components/`, `mobile/`, `pages/` or `hooks/` may not import anything from `src/api/` except types. The long tail that already does is listed in `scripts/offline-baseline.json`; the list only shrinks (a file that stops leaves it via `--update`) and is debt, not precedent. A domain that is online-only on purpose (admin, auth) still puts its calls in a store or repo, not in the view.
|
||||
- **Offline-first is the product promise.** New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
|
||||
- **Imports go downwards** (`lint:layers`, rules in `scripts/lib/layers.mjs`). A component never imports `pages/` or `mobile/`, a shared hook no view, and stores, repos, sync, api, db and the helpers no view at all. A model or helper both sides need moves down (`utils/`, `hooks/`, `types/`); the counts in `scripts/layers-baseline.json` are debt, not precedent.
|
||||
- **No god components or god hooks.** The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"lint:size": "node scripts/size-lint.mjs",
|
||||
"lint:format": "node scripts/format-lint.mjs",
|
||||
"lint:layers": "node scripts/layer-lint.mjs",
|
||||
"lint:offline": "node scripts/offline-lint.mjs",
|
||||
"check:gl-split": "node scripts/check-gl-split.mjs",
|
||||
"theme:lint": "node scripts/theme-lint.mjs",
|
||||
"theme:lint:strict": "node scripts/theme-lint.mjs --strict",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* The comparison behind lint:offline (scripts/offline-lint.mjs).
|
||||
*/
|
||||
import { join } from 'node:path'
|
||||
import { fileList, lowerList, readBaseline, writeBaseline } from './ratchet.mjs'
|
||||
import { sourceGraph } from './imports.mjs'
|
||||
|
||||
/** The layers that render or drive a view, and so must not hold a network call. */
|
||||
export const VIEW_LAYERS = ['components', 'mobile', 'pages', 'hooks']
|
||||
|
||||
/**
|
||||
* The view files that import from src/api/ for anything but types, each with
|
||||
* the imports that do it. A type-only import is erased from the bundle and
|
||||
* carries no request, so it does not count.
|
||||
*/
|
||||
export function bypasses(root) {
|
||||
const found = {}
|
||||
for (const file of sourceGraph(root)) {
|
||||
if (!VIEW_LAYERS.includes(file.layer)) continue
|
||||
const direct = file.imports.filter((imp) => imp.layer === 'api' && !imp.typeOnly)
|
||||
if (direct.length) found[file.key] = direct.map((imp) => `${imp.line}: '${imp.specifier}'`)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the check against the list at baselinePath. A view file outside the
|
||||
* list may not import src/api/. A listed file that no longer does fails the
|
||||
* check as well until --update takes it out, so the list only shrinks and
|
||||
* the next file at that path does not inherit the exemption. Returns the exit
|
||||
* code.
|
||||
*/
|
||||
export function check({
|
||||
root,
|
||||
baselinePath = join(root, 'scripts/offline-baseline.json'),
|
||||
update = false,
|
||||
list = false,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
const found = bypasses(root)
|
||||
const offenders = Object.keys(found)
|
||||
let baseline = readBaseline(baselinePath, fileList)
|
||||
|
||||
if (update) {
|
||||
baseline = lowerList(baseline, offenders)
|
||||
writeBaseline(baselinePath, baseline)
|
||||
}
|
||||
|
||||
const allowed = new Set(baseline)
|
||||
const fresh = offenders.filter((key) => !allowed.has(key))
|
||||
const stale = baseline.filter((key) => !found[key])
|
||||
|
||||
if (list) {
|
||||
for (const [key, direct] of Object.entries(found)) {
|
||||
log(`${allowed.has(key) ? 'listed' : 'new '} ${key}`)
|
||||
for (const line of direct) log(` ${line}`)
|
||||
}
|
||||
}
|
||||
for (const key of fresh) {
|
||||
error(`FAIL ${key} imports src/api/ directly:`)
|
||||
for (const line of found[key]) error(` ${line}`)
|
||||
}
|
||||
if (fresh.length) {
|
||||
error(
|
||||
'A view reads through a repo (src/repo/) or a store and writes through the mutation queue, ' +
|
||||
'so it keeps working offline. A domain that is online-only on purpose still gets its calls ' +
|
||||
'in a store or a repo, not in the view. See client/CLAUDE.md, "Data flow".',
|
||||
)
|
||||
}
|
||||
for (const key of stale) {
|
||||
error(`FAIL ${key} is listed in scripts/offline-baseline.json but no longer imports src/api/ (or is gone).`)
|
||||
}
|
||||
if (stale.length) error('Run npm run lint:offline -- --update to take it off the list.')
|
||||
log(`offline: ${offenders.length} view file(s) import src/api/ directly, ${baseline.length} listed in the baseline`)
|
||||
return fresh.length || stale.length ? 1 : 0
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
[
|
||||
"src/components/Admin/AddonManager.tsx",
|
||||
"src/components/Admin/AdminMcpTokensPanel.tsx",
|
||||
"src/components/Admin/AdminPluginsPanel.tsx",
|
||||
"src/components/Admin/AuditLogPanel.tsx",
|
||||
"src/components/Admin/BackupPanel.tsx",
|
||||
"src/components/Admin/CategoryManager.tsx",
|
||||
"src/components/Admin/DefaultUserSettingsTab.tsx",
|
||||
"src/components/Admin/DevNotificationsPanel.tsx",
|
||||
"src/components/Admin/GitHubPanel.tsx",
|
||||
"src/components/Admin/PackingTemplateManager.tsx",
|
||||
"src/components/Admin/PermissionsPanel.tsx",
|
||||
"src/components/Admin/storage/useStorageAdmin.ts",
|
||||
"src/components/Admin/useGoogleQuota.ts",
|
||||
"src/components/Admin/useInstanceSettings.ts",
|
||||
"src/components/Admin/useNotificationChannels.ts",
|
||||
"src/components/BackgroundTasks/BackgroundTasksWidget.tsx",
|
||||
"src/components/Budget/BudgetPanelPerPersonInline.tsx",
|
||||
"src/components/Budget/CostsPanel.tsx",
|
||||
"src/components/Budget/ReceiptPreviewModal.tsx",
|
||||
"src/components/Budget/receiptUploads.ts",
|
||||
"src/components/Budget/useBudgetPanel.ts",
|
||||
"src/components/Collab/CollabChatLinkPreview.tsx",
|
||||
"src/components/Collab/CollabLinks.tsx",
|
||||
"src/components/Collab/CollabNotes.tsx",
|
||||
"src/components/Collab/CollabNotesAuthedImg.tsx",
|
||||
"src/components/Collab/CollabNotesFilePreviewPortal.tsx",
|
||||
"src/components/Collab/CollabNotesWebsiteThumbnail.tsx",
|
||||
"src/components/Collab/CollabPolls.tsx",
|
||||
"src/components/Collab/useCollabChat.ts",
|
||||
"src/components/Collections/AddPlaceToCollectionModal.tsx",
|
||||
"src/components/Collections/CollectionPicker.tsx",
|
||||
"src/components/Collections/CollectionPlaceDetail.tsx",
|
||||
"src/components/Collections/CopyToTripModal.tsx",
|
||||
"src/components/Collections/ImportFromTripModal.tsx",
|
||||
"src/components/Collections/ListEditorModal.tsx",
|
||||
"src/components/Collections/MSaveToCollectionSheet.tsx",
|
||||
"src/components/Collections/SaveToCollectionModal.tsx",
|
||||
"src/components/Collections/SaveTripPlacesToListModal.tsx",
|
||||
"src/components/Collections/ShareCollectionModal.tsx",
|
||||
"src/components/Dashboard/CollectionsWidget.tsx",
|
||||
"src/components/Dawarich/DawarichAtlasDialog.tsx",
|
||||
"src/components/Files/FileManagerAssignModal.tsx",
|
||||
"src/components/Files/FileManagerAuthedImg.tsx",
|
||||
"src/components/Files/FileManagerImageLightbox.tsx",
|
||||
"src/components/Files/docsync/useConflicts.ts",
|
||||
"src/components/Files/docsync/useDocSync.ts",
|
||||
"src/components/Files/useFileManager.ts",
|
||||
"src/components/Journey/ContributorInviteDialog.tsx",
|
||||
"src/components/Journey/JourneyDetailPageAddTripDialog.tsx",
|
||||
"src/components/Journey/JourneyDetailPageEntryCard.tsx",
|
||||
"src/components/Journey/JourneyDetailPageEntryEditor.tsx",
|
||||
"src/components/Journey/JourneyDetailPageGalleryView.tsx",
|
||||
"src/components/Journey/JourneyDetailPageProviderPicker.tsx",
|
||||
"src/components/Journey/JourneyDetailPageSettingsDialog.tsx",
|
||||
"src/components/Journey/JourneyShareSection.tsx",
|
||||
"src/components/Journey/useConnectedPhotoProviders.ts",
|
||||
"src/components/Journey/useEntryPhotoOrder.ts",
|
||||
"src/components/Journey/useJourneyTripSuggestion.ts",
|
||||
"src/components/Map/MapPluginLayers.tsx",
|
||||
"src/components/Map/MapPluginMarkers.tsx",
|
||||
"src/components/Map/MapView.tsx",
|
||||
"src/components/Map/MapViewGL.tsx",
|
||||
"src/components/Map/RouteCalculator.ts",
|
||||
"src/components/Map/routeUsageCounter.ts",
|
||||
"src/components/Map/usePoiExplore.ts",
|
||||
"src/components/OAuth/ScopeGroupPicker.tsx",
|
||||
"src/components/PDF/TripPDF.tsx",
|
||||
"src/components/Packing/ApplyTemplateButton.tsx",
|
||||
"src/components/Packing/usePackingExport.ts",
|
||||
"src/components/Packing/usePackingListPanel.ts",
|
||||
"src/components/Planner/AddressInput.tsx",
|
||||
"src/components/Planner/AirTrailImportModal.tsx",
|
||||
"src/components/Planner/AirportSelect.tsx",
|
||||
"src/components/Planner/BookingImportModal.tsx",
|
||||
"src/components/Planner/DayPlanSidebar.tsx",
|
||||
"src/components/Planner/FileImportModal.tsx",
|
||||
"src/components/Planner/LocationSelect.tsx",
|
||||
"src/components/Planner/PlaceDetailsColumn.tsx",
|
||||
"src/components/Planner/PlaceFormModal.tsx",
|
||||
"src/components/Planner/PlaceInspector.tsx",
|
||||
"src/components/Planner/TransitSearchPanel.tsx",
|
||||
"src/components/Planner/TripWarningsBanner.tsx",
|
||||
"src/components/Planner/useDayDetail.ts",
|
||||
"src/components/Planner/usePlacesSidebar.ts",
|
||||
"src/components/Plugins/PluginContributions.tsx",
|
||||
"src/components/Plugins/PluginDaySchedule.tsx",
|
||||
"src/components/Plugins/PluginFrame.tsx",
|
||||
"src/components/Plugins/TripCardBadges.tsx",
|
||||
"src/components/Roadtrip/useDayBoundaries.ts",
|
||||
"src/components/Roadtrip/useRefuelSearch.ts",
|
||||
"src/components/Roadtrip/useRoadtripVias.ts",
|
||||
"src/components/Settings/AccountTab.tsx",
|
||||
"src/components/Settings/AirTrailConnectionSection.tsx",
|
||||
"src/components/Settings/ApiKeysSection.tsx",
|
||||
"src/components/Settings/IntegrationsTab.tsx",
|
||||
"src/components/Settings/NotificationsTab.tsx",
|
||||
"src/components/Settings/PasskeysSection.tsx",
|
||||
"src/components/Settings/PhotoProvidersSection.tsx",
|
||||
"src/components/Settings/PluginActivityPanel.tsx",
|
||||
"src/components/Settings/PluginSettingsTab.tsx",
|
||||
"src/components/Settings/useOfflineSettings.ts",
|
||||
"src/components/Studio/useBookPresence.ts",
|
||||
"src/components/Studio/useBookStore.ts",
|
||||
"src/components/Todo/useTodoList.ts",
|
||||
"src/components/Trips/TripFormModal.tsx",
|
||||
"src/components/Trips/useTripShare.ts",
|
||||
"src/components/Vacay/VacayCalendar.tsx",
|
||||
"src/components/Vacay/VacayPersons.tsx",
|
||||
"src/components/Vacay/VacaySettings.tsx",
|
||||
"src/components/Vacay/VacaySharedCalendars.tsx",
|
||||
"src/components/Vacay/holidayRegions.ts",
|
||||
"src/components/shared/PlaceAvatarUpload.tsx",
|
||||
"src/hooks/useAirtrailConnection.ts",
|
||||
"src/hooks/useDawarichConnection.ts",
|
||||
"src/hooks/useDawarichSuggestions.ts",
|
||||
"src/hooks/useInAppNotificationListener.ts",
|
||||
"src/hooks/usePlaceSuggestions.ts",
|
||||
"src/hooks/useRoadtripPreferencesSync.ts",
|
||||
"src/hooks/useTripWebSocket.ts",
|
||||
"src/hooks/useWebPush.ts",
|
||||
"src/mobile/screens/admin/MAdmin.tsx",
|
||||
"src/mobile/screens/admin/MAdminAddonManager.tsx",
|
||||
"src/mobile/screens/admin/MAdminAuditLogPanel.tsx",
|
||||
"src/mobile/screens/admin/MAdminBackupPanel.tsx",
|
||||
"src/mobile/screens/admin/MAdminCategoryManager.tsx",
|
||||
"src/mobile/screens/admin/MAdminDefaultUserSettings.tsx",
|
||||
"src/mobile/screens/admin/MAdminDevNotificationsPanel.tsx",
|
||||
"src/mobile/screens/admin/MAdminGitHubPanel.tsx",
|
||||
"src/mobile/screens/admin/MAdminMcpTokensPanel.tsx",
|
||||
"src/mobile/screens/admin/MAdminNotificationsSection.tsx",
|
||||
"src/mobile/screens/admin/MAdminNotifyMatrix.tsx",
|
||||
"src/mobile/screens/admin/MAdminPackingTemplateManager.tsx",
|
||||
"src/mobile/screens/admin/MAdminPermissionsPanel.tsx",
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx",
|
||||
"src/mobile/screens/admin/MAdminSettingsSection.tsx",
|
||||
"src/mobile/screens/admin/MAdminSheets.tsx",
|
||||
"src/mobile/screens/atlas/MAtlasCountryPopup.tsx",
|
||||
"src/mobile/screens/collections/MCollAddSheet.tsx",
|
||||
"src/mobile/screens/collections/MCollEditSheet.tsx",
|
||||
"src/mobile/screens/collections/MCollPlaceSheet.tsx",
|
||||
"src/mobile/screens/collections/MCollShareSheet.tsx",
|
||||
"src/mobile/screens/collections/MCollTripPickerSheet.tsx",
|
||||
"src/mobile/screens/dashboard/MDashWidgets.tsx",
|
||||
"src/mobile/screens/dashboard/MNewTripSheet.tsx",
|
||||
"src/mobile/screens/journey/MJourneyDetail.tsx",
|
||||
"src/mobile/screens/journey/MJourneyEntrySheet.tsx",
|
||||
"src/mobile/screens/journey/MJourneySettingsSheet.tsx",
|
||||
"src/mobile/screens/settings/MAirTrailConnectionSection.tsx",
|
||||
"src/mobile/screens/settings/MPhotoProvidersSection.tsx",
|
||||
"src/mobile/screens/settings/MScopeGroupPicker.tsx",
|
||||
"src/mobile/screens/settings/MSettingsAccount.tsx",
|
||||
"src/mobile/screens/settings/MSettingsMcp.tsx",
|
||||
"src/mobile/screens/settings/MSettingsNotifications.tsx",
|
||||
"src/mobile/screens/settings/MSettingsPlugins.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesBrowser.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx",
|
||||
"src/mobile/screens/trip/plan/useMPlanTimeline.ts",
|
||||
"src/mobile/screens/trip/sheets/ImpFileStep.tsx",
|
||||
"src/mobile/screens/trip/sheets/ImpListStep.tsx",
|
||||
"src/mobile/screens/trip/sheets/MAccommodationSheet.tsx",
|
||||
"src/mobile/screens/trip/sheets/MDaySheet.tsx",
|
||||
"src/mobile/screens/trip/sheets/MPlaceSheet.tsx",
|
||||
"src/mobile/screens/trip/sheets/PlPlaceSearch.tsx",
|
||||
"src/mobile/screens/trip/tabs/MCollabChat.tsx",
|
||||
"src/mobile/screens/trip/tabs/MCollabNotes.tsx",
|
||||
"src/mobile/screens/trip/tabs/MCollabPolls.tsx",
|
||||
"src/mobile/screens/trip/tabs/MCostsTab.tsx",
|
||||
"src/mobile/screens/trip/tabs/MFileLightbox.tsx",
|
||||
"src/mobile/screens/trip/tabs/MFileLinkSheet.tsx",
|
||||
"src/mobile/screens/trip/tabs/MFileMenuSheet.tsx",
|
||||
"src/mobile/screens/trip/tabs/MFileTrashSheet.tsx",
|
||||
"src/mobile/screens/trip/tabs/MFilesTab.tsx",
|
||||
"src/mobile/screens/trip/tabs/MPackingImportSheet.tsx",
|
||||
"src/mobile/screens/trip/tabs/MPackingListTab.tsx",
|
||||
"src/mobile/screens/vacay/MVacayInviteSheet.tsx",
|
||||
"src/mobile/screens/vacay/MVacaySettingsSheet.tsx",
|
||||
"src/mobile/screens/vacay/MVacayShareSheet.tsx",
|
||||
"src/mobile/screens/vacay/useMVacay.ts",
|
||||
"src/pages/AdminPage.tsx",
|
||||
"src/pages/AtlasPage.tsx",
|
||||
"src/pages/JourneyDetailPage.tsx",
|
||||
"src/pages/OAuthAuthorizePage.tsx",
|
||||
"src/pages/TripPlannerPage.tsx",
|
||||
"src/pages/admin/AdminNotificationsPanel.tsx",
|
||||
"src/pages/admin/AdminNotificationsTab.tsx",
|
||||
"src/pages/admin/AdminSettingsTab.tsx",
|
||||
"src/pages/admin/AdminUserModals.tsx",
|
||||
"src/pages/admin/useAdmin.ts",
|
||||
"src/pages/admin/useNotificationDefaults.ts",
|
||||
"src/pages/atlas/useAtlas.ts",
|
||||
"src/pages/collections/useCollections.ts",
|
||||
"src/pages/dashboard/useDashboard.ts",
|
||||
"src/pages/dashboard/useTripSearch.ts",
|
||||
"src/pages/forgotPassword/useForgotPassword.ts",
|
||||
"src/pages/help/useHelp.ts",
|
||||
"src/pages/join/useJoinTrip.ts",
|
||||
"src/pages/journey/useJourney.ts",
|
||||
"src/pages/journeyDetail/useJourneyDetail.ts",
|
||||
"src/pages/journeyDetail/useProviderPhotoAdds.ts",
|
||||
"src/pages/journeyPublic/useJourneyPublic.ts",
|
||||
"src/pages/journeyStudio/useJourneyStudio.ts",
|
||||
"src/pages/login/useLogin.ts",
|
||||
"src/pages/oauthAuthorize/useOAuthAuthorize.ts",
|
||||
"src/pages/resetPassword/useResetPassword.ts",
|
||||
"src/pages/settings/useSettings.ts",
|
||||
"src/pages/sharedTrip/useSharedTrip.ts",
|
||||
"src/pages/tripPlanner/useBookingEdits.ts",
|
||||
"src/pages/tripPlanner/usePlaceEdits.ts",
|
||||
"src/pages/tripPlanner/usePlaceFormOpeners.ts",
|
||||
"src/pages/tripPlanner/usePlannerDialogs.ts",
|
||||
"src/pages/tripPlanner/useRoadtripStopEdits.ts",
|
||||
"src/pages/tripPlanner/useTripPlanner.ts",
|
||||
"src/pages/vacay/useVacay.ts"
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* lint:offline: views go through the offline core, not around it.
|
||||
*
|
||||
* The data path is component, feature hook, store, repo, then api or Dexie,
|
||||
* and writes go through the mutation queue. A view that imports src/api/
|
||||
* skips the cache, the optimistic write and the idempotent replay, so the
|
||||
* feature breaks offline. Such files kept growing, by about fifteen a month.
|
||||
* The view files (components/, mobile/, pages/, hooks/) that already import
|
||||
* src/api/ for more than types are listed in scripts/offline-baseline.json;
|
||||
* no other view file may, and the list only shrinks.
|
||||
*
|
||||
* npm run lint:offline check against the baseline (CI)
|
||||
* npm run lint:offline -- --list print every view file that imports src/api/
|
||||
* npm run lint:offline -- --update take the files that stopped off the list;
|
||||
* it never adds one
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { check } from './lib/offline.mjs'
|
||||
import { runCli } from './lib/ratchet.mjs'
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
await runCli('lint:offline', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') }))
|
||||
@@ -0,0 +1,71 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { bypasses, check } from '../../../scripts/lib/offline.mjs';
|
||||
import { RatchetError } from '../../../scripts/lib/ratchet.mjs';
|
||||
import { ratchetTree, type RatchetTree } from '../../helpers/ratchetFixture';
|
||||
|
||||
let tree: RatchetTree;
|
||||
afterEach(() => tree?.remove());
|
||||
|
||||
const BASELINE = 'scripts/offline-baseline.json';
|
||||
const DIRECT = "import { tripsApi } from '../../api/client'\n";
|
||||
|
||||
function run(baseline: string[], files: Record<string, string>, update = false) {
|
||||
tree = ratchetTree({
|
||||
[BASELINE]: JSON.stringify(baseline),
|
||||
'src/api/client.ts': 'export const tripsApi = {}\n',
|
||||
...files,
|
||||
});
|
||||
return check({ root: tree.root, baselinePath: tree.path(BASELINE), update, ...tree.out });
|
||||
}
|
||||
|
||||
describe('lint:offline', () => {
|
||||
it('OFFLINE-001: finds view files that import src/api/ for more than types', () => {
|
||||
tree = ratchetTree({
|
||||
'src/components/A/A.tsx': DIRECT,
|
||||
'src/mobile/M.tsx': "import { connect } from '../api/websocket'\n",
|
||||
'src/pages/p/usePage.ts': "const load = () => import('../../api/client')\n",
|
||||
'src/hooks/useTypes.ts': "import type { Trip } from '../api/client'\n",
|
||||
'src/hooks/useMixed.ts': "import { type Trip, tripsApi } from '../api/client'\n",
|
||||
'src/store/s.ts': "import { tripsApi } from '../api/client'\n",
|
||||
'src/repo/r.ts': "import { tripsApi } from '../api/client'\n",
|
||||
'src/components/A/A.test.tsx': DIRECT,
|
||||
'src/components/B.tsx': "import { tripRepo } from '../repo/tripRepo'\n",
|
||||
});
|
||||
expect(bypasses(tree.root)).toEqual({
|
||||
'src/components/A/A.tsx': ["1: '../../api/client'"],
|
||||
'src/hooks/useMixed.ts': ["1: '../api/client'"],
|
||||
'src/mobile/M.tsx': ["1: '../api/websocket'"],
|
||||
'src/pages/p/usePage.ts': ["1: '../../api/client'"],
|
||||
});
|
||||
});
|
||||
|
||||
it('OFFLINE-002: passes the listed files and fails a new one', () => {
|
||||
expect(run(['src/components/A/A.tsx'], { 'src/components/A/A.tsx': DIRECT })).toBe(0);
|
||||
tree.remove();
|
||||
expect(
|
||||
run(['src/components/A/A.tsx'], { 'src/components/A/A.tsx': DIRECT, 'src/components/B/B.tsx': DIRECT })
|
||||
).toBe(1);
|
||||
expect(tree.error.join('\n')).toMatch(/src\/components\/B\/B\.tsx imports src\/api\/ directly/);
|
||||
expect(tree.error.join('\n')).not.toMatch(/A\.tsx imports/);
|
||||
});
|
||||
|
||||
it('OFFLINE-003: fails a listed file that stopped, until --update takes it off', () => {
|
||||
const files = { 'src/components/A/A.tsx': "import { tripRepo } from '../../repo/tripRepo'\n" };
|
||||
expect(run(['src/components/A/A.tsx'], files)).toBe(1);
|
||||
expect(tree.error.join('\n')).toMatch(/A\.tsx is listed .* no longer imports src\/api\//);
|
||||
tree.remove();
|
||||
expect(run(['src/components/A/A.tsx'], files, true)).toBe(0);
|
||||
expect(JSON.parse(readFileSync(tree.path(BASELINE), 'utf8'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('OFFLINE-004: --update never puts a file on the list', () => {
|
||||
expect(run([], { 'src/hooks/useX.ts': "import { tripsApi } from '../api/client'\n" }, true)).toBe(1);
|
||||
expect(JSON.parse(readFileSync(tree.path(BASELINE), 'utf8'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('OFFLINE-005: a broken baseline stops the check', () => {
|
||||
tree = ratchetTree({ [BASELINE]: '{"src/a.ts": 1}', 'src/a.ts': '' });
|
||||
expect(() => check({ root: tree.root, baselinePath: tree.path(BASELINE), ...tree.out })).toThrow(RatchetError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user