From 928e03ce02a31f38347563f2e156ee158b760002 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 1 Nov 2026 17:40:19 +0100 Subject: [PATCH 1/2] Merge pull request #1161 from clusterio/host-pairing Replace host tokens with host pairing From 2e1710d3a96fcf72b2054855f76bf10b43d9069a Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Fri, 30 Oct 2026 22:39:47 +0100 Subject: [PATCH 2/2] Show the same page for missing, deleted and unloadable resources Every view page handled a resource that can't be shown its own way, and users without the subscribe permission were left on a spinner forever. Adds useResourceState and a ResourceState component, used by the host, instance, mod pack, icon reference, user, role and plugin pages and exported for plugin pages. The single item hooks now also return the subscription error. Resolves #1165 --- docs/writing-plugins.md | 29 +++++++++++ .../web_ui/src/components/HostViewPage.tsx | 16 +++--- .../src/components/IconReferencePage.tsx | 9 +++- .../src/components/InstanceViewPage.tsx | 25 +++------- .../web_ui/src/components/ModPackViewPage.tsx | 19 +++---- .../src/components/PluginClusterStatus.tsx | 14 ++++-- .../web_ui/src/components/PluginViewPage.tsx | 39 +++++++-------- .../web_ui/src/components/ResourceState.tsx | 49 +++++++++++++++++++ .../web_ui/src/components/RoleViewPage.tsx | 30 +++--------- .../web_ui/src/components/UserViewPage.tsx | 19 +++---- packages/web_ui/src/index.ts | 2 + packages/web_ui/src/model/host.ts | 4 +- packages/web_ui/src/model/instance.ts | 4 +- packages/web_ui/src/model/mod_pack.ts | 4 +- packages/web_ui/src/model/roles.ts | 4 +- packages/web_ui/src/model/user.tsx | 4 +- packages/web_ui/src/util/useResourceState.ts | 37 ++++++++++++++ test/web_ui/useResourceState.js | 37 ++++++++++++++ 18 files changed, 233 insertions(+), 112 deletions(-) create mode 100644 packages/web_ui/src/components/ResourceState.tsx create mode 100644 packages/web_ui/src/util/useResourceState.ts create mode 100644 test/web_ui/useResourceState.js diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index d2a2a82f..fd571c0e 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -16,6 +16,7 @@ Each entrypoint of a plugin exports a function that attaches handlers to hooks c - [Defining Link Messages](#defining-link-messages) - [Sending Link Messages](#sending-link-messages) - [Handling connection events](#handling-connection-events) +- [Web Pages for a Single Resource](#web-pages-for-a-single-resource) - [Collecting Statistics](#collecting-statistics) - [Adding Custom Commands to clusterioctl](#adding-custom-commands-to-clusterioctl) @@ -842,6 +843,34 @@ This means that if your plugin sends a lot of events or requests, they can end u To avoid this you should be throtteling and/or stopping your requests/events after `drop` has been raised, and continue back as normal when `resume` is raised. +## Web Pages for a Single Resource + +A web page showing one resource from a subscription has more to handle than the resource itself. +It can still be loading, it might not exist, it can be deleted while the page is open, and the subscription is rejected if the user lacks the permission for it. +`useResourceState` and `ResourceState` from `@clusterio/web_ui` show these the same way as the pages for hosts, instances and the other built in resources. + +```tsx +function FrobberPage() { + const id = Number(useParams().id); + const [frobbers, synced, error] = useSyncExternalStore(subscribe, () => frobberSubscriber.getSnapshot()); + const frobber = frobbers.get(id); + const state = useResourceState(frobber, synced, error, id); + + const nav = [{ name: "Frobbers", path: "/frobbers" }, { name: frobber?.name ?? String(id) }]; + if (!frobber) { + return ; + } + + return {frobber.name}; +} +``` + +`useResourceState` returns `"ok"` when there is a value and otherwise `"loading"`, `"missing"`, `"deleted"` or `"error"`. +It tells deleted from missing by remembering if it has seen a value for that id. +`ResourceState` renders the whole page for the states without a value, with a link back to the page at `listPath`. +The `useHost`, `useInstance`, `useUser`, `useRole` and `useModPack` hooks return `[value, synced, error]` so they can be used the same way. + + ## Collecting Statistics Clusterio comes with its own Prometheus client implementation, one part due to Not Invented Here and another part due to collectors in prom-client being difficult to get to work nicely with collecting data from plugins optionally loaded at runtime on different computers. diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx index a7b1d6fc..2328fce7 100644 --- a/packages/web_ui/src/components/HostViewPage.tsx +++ b/packages/web_ui/src/components/HostViewPage.tsx @@ -1,5 +1,5 @@ import React, { useContext, useState } from "react"; -import { Descriptions, Spin, Typography, Button, Space, Popconfirm, Flex, Tooltip } from "antd"; +import { Descriptions, Typography, Button, Space, Popconfirm, Flex, Tooltip } from "antd"; import { ExclamationCircleOutlined } from "@ant-design/icons"; import { useNavigate, useParams } from "react-router-dom"; @@ -17,11 +17,13 @@ import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; import ResourceNotes from "./ResourceNotes"; +import ResourceState from "./ResourceState"; import { MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed, MetricDiskUsed, MetricDiskRatio, MetricRelativeDate, } from "./system_metrics"; import { useSystems } from "../model/system"; +import useResourceState from "../util/useResourceState"; import { hasNpmButtonPermission, NpmButton } from "./NpmButton"; import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton"; @@ -132,21 +134,15 @@ export default function HostViewPage() { let [instances] = useInstances(); const [systems] = useSystems(); const system = systems.get(hostId); - const [host, synced] = useHost(hostId); + const [host, synced, error] = useHost(hostId); + const state = useResourceState(host, synced, error, hostId); const [maxLevel, setMaxLevel] = useState("info"); const canShowNpm = hasNpmButtonPermission(false); const hostInstances = new Map([...instances].filter(([_id, instance]) => instance.assignedHost === hostId)); let nav = [{ name: "Hosts", path: "/hosts" }, { name: host?.name ?? String(hostId) }]; if (!host) { - if (!synced) { - return ; - } - - return - -

Host with id {hostId} was not found on the controller.

-
; + return ; } let hostButtons = diff --git a/packages/web_ui/src/components/IconReferencePage.tsx b/packages/web_ui/src/components/IconReferencePage.tsx index de5263b0..2a8f58dc 100644 --- a/packages/web_ui/src/components/IconReferencePage.tsx +++ b/packages/web_ui/src/components/IconReferencePage.tsx @@ -8,6 +8,8 @@ import FactorioIcon from "./FactorioIcon"; import { useParams } from "react-router-dom"; import { useModPack } from "../model/mod_pack"; import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store"; +import useResourceState from "../util/useResourceState"; +import ResourceState from "./ResourceState"; const { Text } = Typography; @@ -39,7 +41,8 @@ export default function IconReferencePage() { const [filter, setFilter] = useState(""); const [expandedGroups, setExpandedGroups] = useState>(new Set()); - const [modPack] = useModPack(modPackId); + const [modPack, synced, error] = useModPack(modPackId); + const state = useResourceState(modPack, synced, error, modPackId); const manifest = modPack?.exportManifest; const prototypes = useExportPrototypeMetadata(modPack); const filtered = useMemo(() => filterPrototypes(prototypes, filter), [prototypes, filter]); @@ -100,6 +103,10 @@ export default function IconReferencePage() { { name: modPack?.name ?? String(modPackId), path: `/mods/mod-packs/${modPackId}/view` }, { name: "Icon Reference" }, ]; + if (!modPack) { + return ; + } + return ( diff --git a/packages/web_ui/src/components/InstanceViewPage.tsx b/packages/web_ui/src/components/InstanceViewPage.tsx index ab52b69a..ce12ef8d 100644 --- a/packages/web_ui/src/components/InstanceViewPage.tsx +++ b/packages/web_ui/src/components/InstanceViewPage.tsx @@ -1,7 +1,7 @@ import React, { useContext, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { - Alert, Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Segmented, Space, Spin, Tooltip, Typography, + Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Segmented, Space, Tooltip, Typography, message, } from "antd"; import CopyOutlined from "@ant-design/icons/CopyOutlined"; @@ -16,6 +16,7 @@ import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; import ResourceNotes from "./ResourceNotes"; +import ResourceState from "./ResourceState"; import InstanceConfigTree from "./InstanceConfigTree"; import LogConsole, { SelectMaxLogLevel } from "./LogConsole"; import InstanceRcon from "./InstanceRcon"; @@ -31,6 +32,7 @@ import InstanceStatusTag from "./InstanceStatusTag"; import Link from "./Link"; import { instancePublicAddress } from "../util/instance"; import useLocalStorage from "../util/useLocalStorage"; +import useResourceState from "../util/useResourceState"; import { MetricRelativeDate } from "./system_metrics"; type MenuItem = Required["items"][number]; @@ -208,26 +210,15 @@ export default function InstanceViewPage() { let navigate = useNavigate(); let account = useAccount(); - let [instance, synced] = useInstance(instanceId); + let [instance, synced, error] = useInstance(instanceId); + const state = useResourceState(instance, synced, error, instanceId); let [host] = useHost(instance?.assignedHost); let nav = [{ name: "Instances", path: "/instances" }, { name: instance?.name ?? String(instanceId) }]; if (!instance) { - if (!synced) { - return ; - } - - return - Instance with id {instanceId} was not found on the controller.} - type="warning" - action={ - Go back to instances list - } - /> - ; + return ; } return diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 575b3361..f0216254 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -3,7 +3,7 @@ import { useNavigate, useParams } from "react-router-dom"; import { Button, Card, ColorPicker, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination, - Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip, + Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Switch, Modal, Tooltip, TablePaginationConfig, Alert, notification, FormProps, } from "antd"; @@ -22,10 +22,12 @@ import { useModPack } from "../model/mod_pack"; import { useMods } from "../model/mods"; import { useClipboard } from "../util/clipboard"; import notify, { notifyErrorHandler } from "../util/notify"; +import useResourceState from "../util/useResourceState"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import SectionHeader from "./SectionHeader"; import ResourceNotes from "./ResourceNotes"; +import ResourceState from "./ResourceState"; import ModDetails from "./ModDetails"; import { InputPartialVersion } from "./InputVersion"; import Link from "./Link"; @@ -1183,7 +1185,8 @@ export default function ModPackViewPage() { let modPackId = Number(params.id); const [form] = Form.useForm(); - const [modPack, synced] = useModPack(modPackId); + const [modPack, synced, error] = useModPack(modPackId); + const state = useResourceState(modPack, synced, error, modPackId); let prototypes: any = useExportedAsset(modPack, "settings"); let locale: Map = useExportedAsset(modPack, "locale"); let [changes, setChanges] = useState([]); @@ -1253,17 +1256,7 @@ export default function ModPackViewPage() { let nav = [{ name: "Mods", path: "/mods" }, { name: "Mod Packs" }, { name: modPack?.name ?? String(modPackId) }]; if (!modifiedModPack || !modPack) { - if (!synced) { - return - - - ; - } - - return - -

Mod pack with id {modPackId} was not found on the controller.

-
; + return ; } return diff --git a/packages/web_ui/src/components/PluginClusterStatus.tsx b/packages/web_ui/src/components/PluginClusterStatus.tsx index 8c17234f..570c1f1b 100644 --- a/packages/web_ui/src/components/PluginClusterStatus.tsx +++ b/packages/web_ui/src/components/PluginClusterStatus.tsx @@ -35,6 +35,8 @@ type MachinePluginsState = { unknown: Machine[], /** Fetch the plugin lists of connected machines now, instead of waiting for the next system metrics */ refresh: (addresses?: lib.AddressShorthand[]) => Promise, + /** Why machines is not available, if it's not just still loading */ + error: Error | null, }; /** @@ -44,7 +46,7 @@ type MachinePluginsState = { export function useMachinePlugins(): MachinePluginsState { const control = useContext(ControlContext); const account = useAccount(); - const [hosts, hostsSynced] = useHosts(); + const [hosts, hostsSynced, hostsError] = useHosts(); const [systems, systemsSynced] = useSystems(); // Lists fetched directly, used until the system metrics of the machine update const [listed, setListed] = useState(new Map()); @@ -88,8 +90,12 @@ export function useMachinePlugins(): MachinePluginsState { } }, [canList, hostsSynced, connectedKey]); - if (!canList || !hostsSynced || (!systemsSynced && !listed.size)) { - return { machines: undefined, unknown: [], refresh }; + if (!canList) { + const error = new lib.PermissionError("Missing permission core.plugin.list"); + return { machines: undefined, unknown: [], refresh, error }; + } + if (!hostsSynced || (!systemsSynced && !listed.size)) { + return { machines: undefined, unknown: [], refresh, error: hostsError }; } const machines: MachinePlugins[] = []; const unknown: Machine[] = []; @@ -105,7 +111,7 @@ export function useMachinePlugins(): MachinePluginsState { unknown.push(machine); } } - return { machines, unknown, refresh }; + return { machines, unknown, refresh, error: null }; } /** Latest version published on npm of each package */ diff --git a/packages/web_ui/src/components/PluginViewPage.tsx b/packages/web_ui/src/components/PluginViewPage.tsx index 2a7e6c8e..6903f258 100644 --- a/packages/web_ui/src/components/PluginViewPage.tsx +++ b/packages/web_ui/src/components/PluginViewPage.tsx @@ -1,15 +1,15 @@ import React, { useEffect, useContext, useState } from "react"; import { useParams } from "react-router-dom"; -import { Alert, Descriptions, Spin } from "antd"; +import { Alert, Descriptions } from "antd"; import * as lib from "@clusterio/lib"; import type { PluginWebApi } from "@clusterio/lib"; import ControlContext from "./ControlContext"; import PageLayout from "./PageLayout"; -import PageHeader from "./PageHeader"; +import ResourceState from "./ResourceState"; import SectionHeader from "./SectionHeader"; -import notify from "../util/notify"; +import useResourceState from "../util/useResourceState"; import { usePluginInstaller } from "./PluginInstall"; import { PluginMachinesTable, PluginUpdateButton, useLatestVersions, useMachinePlugins } from "./PluginClusterStatus"; import { PluginStatus, computePluginStatuses, pluginStatusKey } from "../util/plugin_status"; @@ -89,18 +89,18 @@ export default function PluginViewPage() { let params = useParams(); const control = useContext(ControlContext); let [pluginList, setPluginList] = useState(); + const [pluginListError, setPluginListError] = useState(null); let pluginName = String(params.name); - const { machines, unknown, refresh } = useMachinePlugins(); + const { machines, unknown, refresh, error: machinesError } = useMachinePlugins(); useEffect(() => { (async () => { let response = await fetch(`${webRoot}api/plugins`); - if (response.ok) { - setPluginList(await response.json()); - } else { - notify("Failed to load plugin list"); + if (!response.ok) { + throw new Error(`Failed to load plugin list: ${response.status} ${response.statusText}`); } - })(); + setPluginList(await response.json()); + })().catch(setPluginListError); }, []); const pluginMeta = pluginList?.find(p => p.name === pluginName); @@ -110,19 +110,16 @@ export default function PluginViewPage() { : undefined; const pluginInfo = control.pluginInfos.get(pluginName); const pluginTitle = pluginInfo?.title ?? status?.title ?? pluginName; + // Machines are only needed for plugins not on the controller, which isn't known until the list has loaded + const error = pluginListError ?? (pluginList ? machinesError : null); + const state = useResourceState( + pluginList && (pluginMeta ?? status), Boolean(pluginList && machines), error, pluginName + ); let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }]; - if (!pluginList || (!pluginMeta && !machines)) { - return - - - ; - } - - if (!pluginMeta && !status) { - return - -

Plugin with name {pluginName} was not found on the controller or any host.

-
; + if (state !== "ok") { + return ; } return diff --git a/packages/web_ui/src/components/ResourceState.tsx b/packages/web_ui/src/components/ResourceState.tsx new file mode 100644 index 00000000..d2587704 --- /dev/null +++ b/packages/web_ui/src/components/ResourceState.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { Result, ResultProps, Spin } from "antd"; + +import Link from "./Link"; +import PageHeader from "./PageHeader"; +import PageLayout from "./PageLayout"; +import type { ResourceStatus } from "../util/useResourceState"; + +type ResourceStateProps = { + /** Status from useResourceState */ + state: ResourceStatus; + /** Capitalised name of the kind of resource, e.g. "Mod pack" */ + type: string; + /** Id or name of the resource */ + id: number | string; + nav: React.ComponentProps["nav"]; + /** Page listing this kind of resource */ + listPath: string; + /** Error from the subscription */ + error: Error | null; +}; + +/** Whole page to render in place of a view page when its resource can't be shown */ +export default function ResourceState(props: ResourceStateProps) { + const { state, type, id, nav } = props; + if (state === "ok") { + return null; + } + + if (state === "loading") { + return + + + ; + } + + const results: Record = { + missing: { status: "warning", title: `${type} not found`, subTitle: `${type} ${id} was not found.` }, + deleted: { status: "warning", title: `${type} ${id} has been deleted` }, + error: { status: "error", title: `Could not load ${type.toLowerCase()}`, subTitle: props.error?.message }, + }; + + return + Back to {type.toLowerCase()} list} + /> + ; +} diff --git a/packages/web_ui/src/components/RoleViewPage.tsx b/packages/web_ui/src/components/RoleViewPage.tsx index 5e61df85..5a7d8ba4 100644 --- a/packages/web_ui/src/components/RoleViewPage.tsx +++ b/packages/web_ui/src/components/RoleViewPage.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useContext, useState, useMemo, useRef } from "react"; +import React, { useEffect, useContext, useState, useMemo } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { Button, Checkbox, Col, Grid, Input, Popconfirm, Row, Space, Spin, Tooltip } from "antd"; +import { Button, Checkbox, Col, Grid, Input, Popconfirm, Row, Space, Tooltip } from "antd"; import { ExclamationCircleOutlined, StarOutlined, DeleteOutlined} from "@ant-design/icons"; import * as lib from "@clusterio/lib"; @@ -12,7 +12,9 @@ import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; import ResourceNotes from "./ResourceNotes"; +import ResourceState from "./ResourceState"; import { notifyErrorHandler } from "../util/notify"; +import useResourceState from "../util/useResourceState"; /** Splits a permission name into parts, removing the last part */ @@ -162,14 +164,14 @@ export default function RoleViewPage() { const navigate = useNavigate(); const account = useAccount(); const control = useContext(ControlContext); - const [role, synced] = useRole(roleId); + const [role, synced, error] = useRole(roleId); + const state = useResourceState(role, synced, error, roleId); // Local edits on top of the live role const [nameEdit, setNameEdit] = useState(); const [descriptionEdit, setDescriptionEdit] = useState(); const [permissionEdits, setPermissionEdits] = useState>({}); const [search, setSearch] = useState(""); - const loadedRoleId = useRef(undefined); // Hidden below lg, where the site sider leaves too little room; undefined until measured const screens = Grid.useBreakpoint(); const showGroupList = screens.lg !== false; @@ -203,27 +205,9 @@ export default function RoleViewPage() { setPermissionEdits({}); }, [roleId]); - if (role) { - loadedRoleId.current = roleId; - } - - // If no role then display either the loading screen or the not found screen if (!role) { const nav = [{ name: "Roles", path: "/roles" }, { name: String(roleId) }]; - - if (!synced) { - return - - - ; - } - - return - -

Role with id {roleId} { - loadedRoleId.current === roleId ? "has been deleted" : "was not found on the controller" - }.

-
; + return ; } const name = nameEdit ?? role.name; diff --git a/packages/web_ui/src/components/UserViewPage.tsx b/packages/web_ui/src/components/UserViewPage.tsx index 02e9733b..5e3581c0 100644 --- a/packages/web_ui/src/components/UserViewPage.tsx +++ b/packages/web_ui/src/components/UserViewPage.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useContext, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Button, Col, Descriptions, Form, Input, Popconfirm, - Row, Table, Tag, Select, Space, Spin, Switch, + Row, Table, Tag, Select, Space, Switch, } from "antd"; import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; @@ -18,7 +18,9 @@ import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; import SectionHeader from "./SectionHeader"; import ResourceNotes from "./ResourceNotes"; +import ResourceState from "./ResourceState"; import useRowNavigation from "../util/useRowNavigation"; +import useResourceState from "../util/useResourceState"; import Link from "./Link"; import notify, { notifyErrorHandler } from "../util/notify"; import { formatDuration } from "../util/time_format"; @@ -40,7 +42,8 @@ export default function UserViewPage() { let account = useAccount(); let control = useContext(ControlContext); let [instances] = useInstances(); - const [user, synced] = useUser(userName); + const [user, synced, error] = useUser(userName); + const state = useResourceState(user, synced, error, userName); let [roles, rolesSynced] = useRoles(); let [form] = Form.useForm(); const statsTable = useTableQueryState<[number, lib.PlayerStats]>({ @@ -78,17 +81,7 @@ export default function UserViewPage() { let nav = [{ name: "Users", path: "/users" }, { name: userName }]; if (!user) { - if (!synced) { - return - - - ; - } - - return - -

User with name {userName} was not found on the controller.

-
; + return ; } let roleSelector = (undefined); + const exists = value !== undefined && value !== null; + if (exists) { + seenId.current = id; + } + return getResourceStatus(exists, seenId.current === id, synced, error); +} diff --git a/test/web_ui/useResourceState.js b/test/web_ui/useResourceState.js new file mode 100644 index 00000000..50666001 --- /dev/null +++ b/test/web_ui/useResourceState.js @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; + +import { getResourceStatus } from "../../packages/web_ui/src/util/useResourceState.ts"; + +describe("web_ui/util/useResourceState", function() { + describe("getResourceStatus()", function() { + const error = new Error("Permission denied"); + + it("should be ok when the resource exists", function() { + assert.equal(getResourceStatus(true, true, true, null), "ok"); + }); + it("should keep showing a resource after the subscription stopped syncing", function() { + assert.equal(getResourceStatus(true, true, false, null), "ok"); + assert.equal(getResourceStatus(true, true, false, error), "ok"); + }); + it("should be loading until the subscription has synced", function() { + assert.equal(getResourceStatus(false, false, false, null), "loading"); + }); + it("should be loading while resyncing a resource that was seen", function() { + assert.equal(getResourceStatus(false, true, false, null), "loading"); + }); + it("should be missing when synced without having seen the resource", function() { + assert.equal(getResourceStatus(false, false, true, null), "missing"); + }); + it("should be deleted when synced after having seen the resource", function() { + assert.equal(getResourceStatus(false, true, true, null), "deleted"); + }); + it("should be error when the subscription failed", function() { + assert.equal(getResourceStatus(false, false, false, error), "error"); + assert.equal(getResourceStatus(false, true, false, error), "error"); + }); + it("should ignore the error of an earlier attempt once synced", function() { + assert.equal(getResourceStatus(false, false, true, error), "missing"); + assert.equal(getResourceStatus(false, true, true, error), "deleted"); + }); + }); +});