Guide: macOS Internet Speed Test Script for IT Professionals

Reliable connectivity is now a baseline requirement for modern IT operations. When users complain about “slow internet,” support teams need hard data—not hunches—to diagnose problems quickly and to demonstrate whether the issue is local, network-wide, or ISP-related. This post walks through a shell script for macOS Internet Speed Test that leverages Apple’s built-in networkQuality utility, enriches the raw results (latency stats, adapter details), and optionally saves them to NinjaOne custom fields (multiline or WYSIWYG). If you need to complete an Internet Speed Test on macOS with a shell script, this tool does it in a way that’s repeatable, auditable, and friendly to MSP workflows.

Background

Apple introduced networkQuality in macOS Monterey (12.0) to provide service-quality measurements—download and upload throughput, round-trip times, and more. The script you provided wraps this native capability with operational guardrails and high-value automation:

  • Operational checks: Ensures root privileges, verifies networkQuality availability, validates NinjaOne field names, and locates ninjarmm-cli.
  • Data hygiene: Converts JSON to XML (plist) for robust field extraction, calculates jitter and average latency, and captures interface + MAC address.
  • Reporting at scale: Writes results to either a Multiline Custom Field (plain text) or a WYSIWYG Custom Field (rich HTML with cards + a historical table). An append mode supports longitudinal tracking per device.
  • Fleet-friendly throttling: Optionally sleeps a random 1–60 minutes before running, preventing hundreds of endpoints from testing simultaneously.

For IT professionals and MSPs, this combination turns an Internet Speed Test macOS shell script into a repeatable telemetry probe that can populate asset records automatically.

The Script

#!/usr/bin/env bash
#
# Description: Executes an internet speed test using macOS's built-in speed test. https://support.apple.com/en-us/101942
# By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
# Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
# Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
# Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
# Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
# Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
# Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
# EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
#
# Preset Parameter: --multilineCustomField "nameOfAMultilineCustomField"
#		Optionally specify the name of a multiline custom field to save the results.
#
# Preset Parameter: --wysiwygCustomField "nameOfAWYSIWYGCustomField"
#		Optionally specify the name of a WYSIWYG custom field to save the results.
#
# Preset Parameter: --append
#		If saving the results to a multiline or WYSIWYG custom field, append the results instead of overwriting them.
#
# Preset Parameter: --sleepBeforeRunning
#		Sleep for a random duration between 0 and 60 minutes before running the speed test.
#
# Preset Parameter: --help
#		Displays help text.
#
# Version: 1.0
# Release Notes: Initial Release

_arg_multilineCustomField=
_arg_wysiwygCustomField=
_arg_append="off"
_arg_sleepBeforeRunning="off"

# Converts a string input into an HTML table format.
convertToHTMLTable() {
  # Default delimiter is a space
  local _arg_delimiter=" "
  local _arg_inputObject

  # Parse command-line arguments
  while test $# -gt 0; do
    _key="$1"
    case "$_key" in
      --delimiter | -d)
        test $# -lt 2 && echo "[Error] Missing value for the required argument" >&2 && return 1
        _arg_delimiter=$2
        shift
        ;;
      --*)
        echo "[Error] Got an unexpected argument" >&2
        return 1
        ;;
      *)
        _arg_inputObject=$1
        ;;
    esac
    shift
  done

  # If no input object is provided, check for input from stdin
  if [[ -z $_arg_inputObject ]]; then
    if [ -p /dev/stdin ]; then
      _arg_inputObject=$(cat)
    else
      echo "[Error] Missing input object to convert to table" >&2
      return 1
    fi
  fi

  # Escape special HTML characters in the input
  _arg_inputObject=${_arg_inputObject//&/&}
  _arg_inputObject=${_arg_inputObject//</&lt;}
  _arg_inputObject=${_arg_inputObject//>/&gt;}
  _arg_inputObject=${_arg_inputObject//\"/&quot;}
  _arg_inputObject=${_arg_inputObject//\'/&#39;}

  # Start building the HTML table
  local htmlTable="<table>\n"

  # Add the header row by processing the first line of input
  htmlTable+=$(printf '%b' "$_arg_inputObject" | head -n1 | awk -F "$_arg_delimiter" '{
    printf "<tr>"
    for (i=1; i<=NF; i++) {
      printf "<th>%s</th>", $i
    }
    printf "</tr>"
    }')
  htmlTable+="\n"

  # Add the data rows by processing the remaining lines of input
  htmlTable+=$(printf '%b' "$_arg_inputObject" | tail -n +2 | awk -F "$_arg_delimiter" '{
    printf "<tr>"
    for (i=1; i<=NF; i++) {
      printf "<td>%s</td>", $i
    }
    print "</tr>"
    }')
  htmlTable+="\n</table>"

  # Output the final HTML table
  printf '%b' "$htmlTable" '\n'
}

print_help() {
  printf '\n\n%s\n\n' 'Usage: [--multilineCustomField|-m <arg>] [--wysiwygCustomField|-w <arg>] [--append|-a] [--sleepBeforeRunning|-s] [--help|-h]'
  printf '%s\n' 'Preset Parameter: --multilineCustomField "90"'
  printf '\t%s\n' "Optionally specify the name of a multiline custom field to save the results to."
  printf '%s\n' 'Preset Parameter: --wysiwygCustomField "90"'
  printf '\t%s\n' "Optionally specify the name of a multiline custom field to save the results to."
  printf '%s\n' 'Preset Parameter: --append'
  printf '\t%s\n' "If saving the results to a multiline or WYSIWYG custom field, append the results instead of overwriting them."
  printf '%s\n' 'Preset Parameter: --sleepBeforeRunning'
  printf '\t%s\n' "Sleep for a random duration between 0 and 60 minutes before running the speed test."
  printf '%s\n' 'Preset Parameter: --help'
  printf '\t%s\n' "Displays this help menu."
}

die() {
  local _ret="${2:-1}"
  echo "$1" >&2
  test "${_PRINT_HELP:-no}" = yes && print_help >&2
  exit "${_ret}"
}

parse_commandline() {
  while test $# -gt 0; do
    _key="$1"
    case "$_key" in
      --multilineCustomField | --multilinecustomfield | --multiline | -m)
        test $# -lt 2 && die "[Error] Missing value for the optional argument '$_key'." 1
        _arg_multilineCustomField=$2
        shift
        ;;
      --multilineCustomField=*)
        _arg_multilineCustomField="${_key##--multilineCustomField=}"
        ;;
      --wysiwygCustomField | --wysiwygcustomfield | --wysiwyg | -w)
        test $# -lt 2 && die "[Error] Missing value for the optional argument '$_key'." 1
        _arg_wysiwygCustomField=$2
        shift
        ;;
      --wysiwygCustomField=*)
        _arg_wysiwygCustomField="${_key##--wysiwygCustomField=}"
        ;;
      --append | -a)
        _arg_append="on"
        ;;
      --sleepBeforeRunning | --sleepbeforerunning | --sleep | -s)
        _arg_sleepBeforeRunning="on"
        ;;
      --help | -h)
        _PRINT_HELP=yes die
        ;;
      *)
        _PRINT_HELP=yes die "[Error] Got an unexpected argument '$1'" 1
        ;;
    esac
    shift
  done
}

echo " "
parse_commandline "$@"

# If script form variables are used, replace the command line parameters with their value.
if [[ -n $multilineCustomFieldName ]]; then
  _arg_multilineCustomField="$multilineCustomFieldName"
fi
if [[ -n $wysiwygCustomFieldName ]]; then
  _arg_wysiwygCustomField="$wysiwygCustomFieldName"
fi
if [[ -n $appendToCustomField && $appendToCustomField == "true" ]]; then
  _arg_append="on"
fi
if [[ -n $sleepBeforeRunning && $sleepBeforeRunning == "true" ]]; then
  _arg_sleepBeforeRunning="on"
fi

# Ensure the script is run with root permissions
if [[ $(id -u) -ne 0 ]]; then
  _PRINT_HELP=no die "[Error] This script must be run with root permissions. Try running it with sudo or as the system/root user." 1
fi

# Check if the networkQuality command is available and the macOS version is sufficient
networkQualityAvailable=$(command -v networkQuality)
currentOSVersion=$(sw_vers -productVersion)
if [[ -z "$networkQualityAvailable" ]]; then
  _PRINT_HELP=no die "[Error] This script requires the networkQuality command, which is included with macOS starting with version 12.0 (Monterey). You are currently running '$currentOSVersion'." 1
fi

# Validate the multiline custom field name if provided
if [[ -n "$_arg_multilineCustomField" ]]; then
  _arg_multilineCustomField=$(echo "$_arg_multilineCustomField" | awk '{$1=$1; print}')

  if [[ -z "$_arg_multilineCustomField" ]]; then
    _PRINT_HELP=yes die "[Error] The 'Multiline Custom Field Name' is invalid.
    [Error] Please provide a valid multiline custom field name to save the results, or leave it blank.
    [Error] https://ninjarmm.zendesk.com/hc/articles/360060920631-Custom-Field-Setup" 1
  fi

  if [[ "$_arg_multilineCustomField" =~ [^[:alnum:]] ]]; then
    _PRINT_HELP=yes die "[Error] The 'Multiline Custom Field Name' is invalid.
    [Error] Please provide a valid multiline custom field name to save the results, or leave it blank.
    [Error] https://ninjarmm.zendesk.com/hc/articles/360060920631-Custom-Field-Setup" 1
  fi
fi

# Validate the WYSIWYG custom field name if provided
if [[ -n "$_arg_wysiwygCustomField" ]]; then
  _arg_wysiwygCustomField=$(echo "$_arg_wysiwygCustomField" | awk '{$1=$1; print}')

  if [[ -z "$_arg_wysiwygCustomField" ]]; then
    _PRINT_HELP=yes die "[Error] The 'WYSIWYG Custom Field Name' is invalid.
    [Error] Please provide a valid WYSIWYG custom field name to save the results, or leave it blank.
    [Error] https://ninjarmm.zendesk.com/hc/articles/360060920631-Custom-Field-Setup" 1
  fi

  if [[ "$_arg_wysiwygCustomField" =~ [^[:alnum:]] ]]; then
    _PRINT_HELP=yes die "[Error] The 'WYSIWYG Custom Field Name' is invalid.
    [Error] Please provide a valid WYSIWYG custom field name to save the results, or leave it blank.
    [Error] https://ninjarmm.zendesk.com/hc/articles/360060920631-Custom-Field-Setup" 1
  fi
fi

# Ensure the multiline and WYSIWYG custom fields are not identical
if [[ -n "$_arg_multilineCustomField" && -n "$_arg_wysiwygCustomField" && "$_arg_multilineCustomField" == "$_arg_wysiwygCustomField" ]]; then
  _PRINT_HELP=yes die "[Error] The multiline and WYSIWYG custom fields you have given '$_arg_multilineCustomField' are identical.
    [Error] Please provide two different valid custom fields to save the results, or leave one or more blank.
    [Error] https://ninjarmm.zendesk.com/hc/articles/360060920631-Custom-Field-Setup" 1
fi

# Locate the ninjarmm-cli tool if custom fields are specified
if [[ -n "$_arg_multilineCustomField" || -n "$_arg_wysiwygCustomField" ]]; then
  if [[ -n $NINJA_DATA_PATH && -f "$NINJA_DATA_PATH/ninjarmm-cli" ]]; then
    ninjarmmcliPath="$NINJA_DATA_PATH/ninjarmm-cli"
  elif [[ -f "/Applications/NinjaRMMAgent/programdata/ninjarmm-cli" ]]; then
    ninjarmmcliPath=/Applications/NinjaRMMAgent/programdata/ninjarmm-cli
  else
    _PRINT_HELP=no die "[Error] Unable to locate ninjarmm-cli. Ninjarmm-cli is required to set custom fields.
    https://ninjarmm.zendesk.com/hc/articles/4405408656013-Custom-Fields-and-Documentation-CLI-and-Scripting#h_01FB4NZ6GCG2V53NS4T1RQX8PY" 1
  fi
fi

# Ensure a valid custom field name is provided if appending results
if [[ "$_arg_append" == "on" && -z "$_arg_multilineCustomField" && -z "$_arg_wysiwygCustomField" ]]; then
  _PRINT_HELP=yes die "[Error] You must provide a valid custom field name to append to.
    [Error] https://ninjarmm.zendesk.com/hc/articles/360060920631-Custom-Field-Setup" 1
fi

# Initialize the exit code
if [[ -z "$exitCode" ]]; then
  exitCode=0
fi

# Sleep for a random duration if requested
if [[ "$_arg_sleepBeforeRunning" == "on" ]]; then
  sleepForInSeconds=$((1 + RANDOM % 3600))
  sleepForInMinutes=$(awk "BEGIN {printf \"%.2f\", $sleepForInSeconds / 60}")
  echo "Waiting for $sleepForInMinutes minutes before performing the Speedtest as requested."
  echo ""
  sleep $sleepForInSeconds
fi

# Start the network speed test
echo "Starting the network speed test."
if ! speedTestResultsJSON=$(mktemp); then
  _PRINT_HELP=no die "[Error] Failed to create a temporary file to save results." 1
fi

speedTestStartDate=$(date "+%x %r")
if ! networkQuality -svc > "$speedTestResultsJSON"; then
  _PRINT_HELP=no die "[Error] Failed to complete the speed test." 1
fi

# Ensure the results file is not empty
if [[ ! -s "$speedTestResultsJSON" ]]; then
  echo "[Error] The speed test results are empty." >&2
  if ! rm "$speedTestResultsJSON"; then
    echo "[Error] Failed to clean up '$speedTestResultsJSON'." >&2
  fi

  exit 1
fi
echo "The network speed test has completed."
echo ""

# Format the results of the speed test
echo "Formatting the results of the speed test."
if ! speedTestResultsXML=$(mktemp); then
  _PRINT_HELP=no die "[Error] Failed to create a temporary file to save the formatted results." 1
fi

jsonResults=$(cat "$speedTestResultsJSON")
if ! plutil -convert xml1 -o - "$speedTestResultsJSON" > "$speedTestResultsXML"; then
  if ! rm "$speedTestResultsJSON"; then
    echo "[Error] Failed to clean up '$speedTestResultsJSON'." >&2
  fi

  if ! rm "$speedTestResultsXML"; then
    echo "[Error] Failed to clean up '$speedTestResultsXML'." >&2
  fi

  _PRINT_HELP=no die "[Error] Failed to convert the JSON results to a plist.
  ### JSON Results ###
  $jsonResults
  " 1
fi

# Clean up the temporary JSON file
if ! rm "$speedTestResultsJSON"; then
  echo "[Error] Failed to clean up '$speedTestResultsJSON'." >&2
fi

# Ensure the XML results file is not empty
if [[ ! -s "$speedTestResultsXML" ]]; then
  echo "[Error] The speed test results are empty." >&2
  if ! rm "$speedTestResultsXML"; then
    echo "[Error] Failed to clean up '$speedTestResultsXML'." >&2
  fi

  exit 1
fi

# Extract the server used for the speed test
speedTestServer=$(/usr/libexec/PlistBuddy -c "Print :test_endpoint:" "$speedTestResultsXML")
if [[ -z "$speedTestServer" ]]; then
  echo "[Error] Failed to retrieve the server used for the speed test." >&2
  exitCode=1
fi

# Extract the network adapter information
if interfaceName=$(/usr/libexec/PlistBuddy -c "Print :interface_name:" "$speedTestResultsXML"); then
  networkSetup=$(networksetup -listallhardwareports | grep -A1 -B1 "$interfaceName")
  hardwarePort=$(echo "$networkSetup" | grep "Hardware Port: " | sed "s/Hardware Port: //")
  macAddress=$(echo "$networkSetup" | grep "Ethernet Address: " | sed "s/Ethernet Address: //")

  case $hardwarePort in
    "Wi-Fi")
      adapterType="Wi-Fi"
      ;;
    "Ethernet Adapter")
      adapterType="Wired"
      ;;
    *)
      adapterType="Other"
      ;;
  esac
fi

# Ensure the network adapter information is valid
if [[ -z "$interfaceName" ]]; then
  echo "[Error] Failed to retrieve the network adapter used for the test." >&2
  exitCode=1
fi

# Extract the download speed and convert it to Mbps
if downloadSpeed=$(/usr/libexec/PlistBuddy -c "Print :dl_throughput:" "$speedTestResultsXML") 2> /dev/null; then
  downloadSpeed=$(echo "$downloadSpeed" | awk '{ printf "%.2f\n", $1 / 1000000 }')
else
  echo "[Error] The results did not contain a download speed." >&2
  exitCode=1
fi

# Extract the upload speed and convert it to Mbps
if uploadSpeed=$(/usr/libexec/PlistBuddy -c "Print :ul_throughput:" "$speedTestResultsXML") 2> /dev/null; then
  uploadSpeed=$(echo "$uploadSpeed" | awk '{ printf "%.2f\n", $1 / 1000000 }')
else
  echo "[Error] The results did not contain an upload speed." >&2
  exitCode=1
fi

# Extract latency information and calculate statistics
numberOfLatencyEntries=0
latencyEntries=
minLatency=
maxLatency=
while /usr/libexec/PlistBuddy -c "Print :il_h2_req_resp:$numberOfLatencyEntries" "$speedTestResultsXML" &> /dev/null; do
  latencyEntry=$(/usr/libexec/PlistBuddy -c "Print :il_h2_req_resp:$numberOfLatencyEntries" "$speedTestResultsXML")
  latencyEntries+=$latencyEntry$'\n'
  latencyEntry=$(awk "BEGIN { printf \"%.3f\", $latencyEntry }")

  if [[ -z "$maxLatency" ]]; then
    maxLatency="$latencyEntry"
  fi

  if [[ -z "$minLatency" ]]; then
    minLatency="$latencyEntry"
  fi

  if (($(awk "BEGIN {print ($latencyEntry > $maxLatency)}"))); then
    maxLatency="$latencyEntry"
  fi

  if (($(awk "BEGIN {print ($latencyEntry < $minLatency)}"))); then
    minLatency="$latencyEntry"
  fi

  numberOfLatencyEntries=$((numberOfLatencyEntries + 1))
done

# Calculate average latency and jitter
if [[ -n "$latencyEntries" ]]; then
  latencyEntries=$(echo "$latencyEntries" | sed '/^[[:space:]]*$/d')

  avgLatency=$(awk 'NF && $1 ~ /^[0-9.]+$/ { sum += $1; count++ } END { if (count > 0) printf "%.3f\n", sum / count }' <<< "$latencyEntries")
  jitter=$(echo "$latencyEntries" | awk '
  { sum += $1; data[NR] = $1 }
  END {
    avg = sum / NR
    for (i = 1; i <= NR; i++) {
      diff = data[i] - avg
      sq_diff += diff * diff
    }
    stdev = sqrt(sq_diff / NR)
    printf "%.3f\n", stdev
  }')
fi

# Clean up the temporary XML file
if ! rm "$speedTestResultsXML"; then
  echo "[Error] Failed to clean up '$speedTestResultsXML'." >&2
fi

echo ""

# Save results to the multiline custom field if specified
if [[ -n "$_arg_multilineCustomField" ]]; then
  echo "Attempting to set the custom field '$_arg_multilineCustomField'."

  multilineValue="Date : $speedTestStartDate
Server : $speedTestServer
Down : $downloadSpeed Mbps
Up : $uploadSpeed Mbps
Interface : $interfaceName
MacAddress : $macAddress
Jitter : $jitter ms
Latency : $avgLatency ms
Low : $minLatency ms
High : $maxLatency ms"

  # Append existing information if requested
  if [[ "$_arg_append" == "on" ]]; then
    echo "Retrieving existing information from '$_arg_multilineCustomField'."

    if ! existingMultiline=$("$ninjarmmcliPath" get "$_arg_multilineCustomField" 2>&1); then
      echo "[Warning] $existingMultiline"
    else
      if [[ -n "$existingMultiline" ]]; then
        existingMultiline=$(echo "$existingMultiline" | grep -v "This information has been truncated to accommodate the character limit of 10,000 characters.")
        multilineValue+=$'\n\n'"$existingMultiline"
      fi

      echo "Successfully retrieved the existing information from the custom field '$_arg_multilineCustomField'."
    fi
  fi

  # Trim the output if it exceeds the character limit
  characterCount=${#multilineValue}
  if [[ "$characterCount" -gt 9500 ]]; then
    echo "[Warning] The character count exceeds the character limit of 10,000 characters."
    echo "[Warning] Trimming the output until the character limit is satisfied."

    multilineValue="This information has been truncated to accommodate the character limit of 10,000 characters."$'\n'"$multilineValue"
    while [[ $characterCount -gt 9500 ]]; do
      totalLines=$(echo "$multilineValue" | wc -l | xargs)

      exceededAmount=$((characterCount - 9500))
      if [[ "$exceededAmount" -lt 1000 ]]; then
        batchSize=1
      else
        batchSize=$(((exceededAmount + 999) / 1000))
        [[ $batchSize -lt 1 ]] && batchSize=1
      fi

      # Keep only the remaining lines within the limit
      linesToKeep=$((totalLines - batchSize))
      multilineValue=$(echo "$multilineValue" | head -n $linesToKeep)

      characterCount=${#multilineValue}
    done
  fi

  # Attempt to set the multiline custom field with the formatted value
  if ! output=$("$ninjarmmcliPath" set "$_arg_multilineCustomField" "$multilineValue" 2>&1); then
    echo "[Error] $output" >&2
    ExitCode=1
  else
    echo "Successfully set the custom field '$_arg_multilineCustomField'."
  fi

  echo ""
fi

# Check if the WYSIWYG custom field is specified
if [[ -n "$_arg_wysiwygCustomField" ]]; then
  echo "Attempting to set the custom field '$_arg_wysiwygCustomField'."

  # Initialize the WYSIWYG value with a container div
  wysiwygValue="<div>"

  # Create the speed test results card with HTML structure
  speedTestCard="<div class='card flex-grow-1'>
    <div class='card-title-box'>
        <div class='card-title'><i class='fa-solid fa-gauge-high'></i>&nbsp;&nbsp;Speed Test Results</div>
        <div class='card-link-box'>
            <a href='https://support.apple.com/en-us/101942' target='_blank' class='card-link' rel='nofollow noopener noreferrer'>
                <i class='fas fa-arrow-up-right-from-square' style='color: #337ab7;'></i>
            </a>
        </div>
    </div>
    <div class='card-body' style='white-space: nowrap'>
        <div class='container' style='padding-left: 0px'>
            <div class='row'>
                <div class='col-sm'>
                    <p class='card-text'><b>Date</b><br>$speedTestStartDate</p>
                </div>
                <div class='col-md'>
                    <p class='card-text' style='white-space: nowrap;'><b>Speed Test Server</b><br>$speedTestServer</p>
                </div>
                <div class='col-md'>
                    <p class='card-text' style='white-space: nowrap;'><b>$interfaceName</b><br><i class='fa-solid fa-ethernet'></i>&nbsp;&nbsp;$macAddress</p>
                </div>
            </div>
        </div>
        <div class='container my-4' style='width: 70em'>
            <div class='row' style='padding-left: 0px'>
                <div class='col-sm-4'>
                    <div class='stat-card' style='display: flex;'>
                        <div class='stat-value' style='color: #008001; white-space: nowrap;'>$downloadSpeed Mbps</div>
                        <div class='stat-desc'><i class='fa-solid fa-circle-down'></i>&nbsp;&nbsp;Download</div>
                    </div>
                </div>
                <div class='col-sm-4'>
                    <div class='stat-card' style='display: flex;'>
                        <div class='stat-value' style='color: #008001; white-space: nowrap;'>$uploadSpeed Mbps</div>
                        <div class='stat-desc'><i class='fa-solid fa-circle-up'></i>&nbsp;&nbsp;Upload</div>
                    </div>
                </div>
                <div class='col-sm-4'>
                    <div class='stat-card' style='display: flex;'>
                        <div class='stat-value' style='color: #008001; white-space: nowrap;'>$jitter ms</div>
                        <div class='stat-desc'><i class='fa-solid fa-chart-line'></i>&nbsp;&nbsp;Jitter</div>
                    </div>
                </div>
            </div>
            <div class='row' style='padding-left: 0px'>
                <div class='col-sm-4'>
                    <div class='stat-card' style='display: flex;'>
                        <div class='stat-value' style='color: #008001; white-space: nowrap;'>$avgLatency ms</div>
                        <div class='stat-desc'><i class='fa-solid fa-server'></i>&nbsp;&nbsp;Latency</div>
                    </div>
                </div>
                <div class='col-sm-4'>
                    <div class='stat-card' style='display: flex;'>
                        <div class='stat-value' style='color: #008001; white-space: nowrap;'>$maxLatency ms</div>
                        <div class='stat-desc'><i class='fa-solid fa-chevron-up'></i>&nbsp;&nbsp;High</div>
                    </div>
                </div>
                <div class='col-sm-4'>
                    <div class='stat-card' style='display: flex;'>
                        <div class='stat-value' style='color: #008001; white-space: nowrap;'>$minLatency ms</div>
                        <div class='stat-desc'><i class='fa-solid fa-chevron-down'></i>&nbsp;&nbsp;Low</div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>"

  # Adjust the icon based on the adapter type
  case $adapterType in
    "Wi-Fi")
      speedTestCard=${speedTestCard//"fa-solid fa-ethernet"/"fa-solid fa-wifi"}
      ;;
    "Other")
      speedTestCard=${speedTestCard//"fa-solid fa-ethernet"/"fa-solid fa-circle-question"}
      ;;
  esac

  # Append the speed test card to the WYSIWYG value
  wysiwygValue+=$'\n'"$speedTestCard"

  # Prepare past results for appending if requested
  pastResults="$speedTestStartDate;|;$speedTestServer;|;$downloadSpeed Mbps;|;$uploadSpeed Mbps;|;$interfaceName;|;$macAddress;|;$jitter ms;|;$avgLatency ms;|;$minLatency ms;|;$maxLatency ms"
  if [[ "$_arg_append" == "on" ]]; then
    echo "Retrieving existing information from '$_arg_wysiwygCustomField'."

    # Retrieve existing WYSIWYG field data
    if ! existingWysiwyg=$("$ninjarmmcliPath" get "$_arg_wysiwygCustomField" 2>&1); then
      echo "[Warning] $existingWysiwyg"
    else
      if [[ -n "$existingWysiwyg" ]]; then
        wysiwygJSON=$(mktemp)
        wysiwygXML=$(mktemp)
        echo "$existingWysiwyg" > "$wysiwygJSON"

        # Convert existing data to XML and extract past results
        if plutil -convert xml1 -o - "$wysiwygJSON" > "$wysiwygXML"; then
          codeBlock=$(plutil -extract html raw -o - "$wysiwygXML" | awk '/<code class="d-none">/ {codeBlock=1} codeBlock' | awk '/<\/code>/ {exit} {print}' | grep -v "Date;|;Server" | tail -n +2)
          codeBlock=$(echo "$codeBlock" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')
          pastResults+=$'\n'$codeBlock

          echo "Successfully retrieved the existing information from the custom field '$_arg_wysiwygCustomField'."
        else
          echo "[Error] Failed to parse the existing information." >&2
          exitCode=1
        fi

        # Clean up temporary files
        if ! rm "$wysiwygJSON"; then
          echo "[Error] Failed to clean up '$wysiwygJSON'." >&2
          exitCode=1
        fi

        if ! rm "$wysiwygXML"; then
          echo "[Error] Failed to clean up '$wysiwygXML'." >&2
          exitCode=1
        fi
      else
        echo "Successfully retrieved the existing information from the custom field '$_arg_wysiwygCustomField'."
      fi
    fi
  fi

  # Format past results into an HTML table
  totalLines=$(echo "$pastResults" | wc -l | xargs)
  if [[ "$totalLines" -gt 1 ]]; then
    pastResults="Date;|;Server;|;Down;|;Up;|;Interface;|;MAC Address;|;Jitter;|;Latency;|;Low;|;High"$'\n'"$pastResults"
    echo "$pastResults" > "/tmp/pastresults.txt"
    pastResultsTable=$(echo "$pastResults" | awk 'NR != 2' | convertToHTMLTable -d ";[|];")
    pastResultsTable=$(sed -e 's/<th>/<th><b>/g' \
      -e 's/<\/th>/<\/b><\/th>/g' \
      -e "s/<th><b>Date/<th style='width: 14em'><b>Date/g" \
      -e "s/<th><b>Down/<th style='width: 9em'><i class='fa-solid fa-circle-down'><\/i>\&nbsp;\&nbsp;<b>Down/g" \
      -e "s/<th><b>Up/<th style='width: 9em'><i class='fa-solid fa-circle-up'><\/i>\&nbsp;\&nbsp;<b>Up/g" \
      -e "s/<th><b>Jitter/<th style='width: 7em'><i class='fa-solid fa-chart-line'><\/i>\&nbsp;\&nbsp;<b>Jitter/g" \
      -e "s/<th><b>Latency/<th style='width: 7em'><i class='fa-solid fa-server'><\/i>\&nbsp;\&nbsp;<b>Latency/g" \
      -e "s/<th><b>High/<th style='width: 7em'><i class='fa-solid fa-chevron-up'><\/i>\&nbsp;\&nbsp;<b>High/g" \
      -e "s/<th><b>Low/<th style='width: 7em'><i class='fa-solid fa-chevron-down'><\/i>\&nbsp;\&nbsp;<b>Low/g" \
      <<< "$pastResultsTable")

    # Create a card for past results
    pastResultsCard="<div class='card flex-grow-1'>
  <div class='card-title-box'>
    <div class='card-title'><i class='fa-solid fa-book'></i>&nbsp;&nbsp;Past Results</div>
  </div>
  <div class='card-body' style='white-space: nowrap'>
    $pastResultsTable
  </div>
</div>"
    wysiwygValue+=$'\n'"$pastResultsCard"
  fi

  # Append hidden code block with past results
  wysiwygValue+=$'\n'"<code class='d-none'>
  $pastResults
</code>"

  # Close the WYSIWYG container div
  wysiwygValue+=$'\n'"</div>"

  # Trim the output if it exceeds the character limit
  characterCount=${#wysiwygValue}
  if [[ "$characterCount" -gt 195000 ]]; then
    echo "[Warning] The character count exceeds the character limit of 200,000 characters."
    echo "[Warning] Trimming the output until the character limit is satisfied."

    while [[ "$characterCount" -gt 195000 ]]; do
      totalLines=$(echo "$pastResults" | wc -l | xargs)

      exceededAmount=$((characterCount - 195000))
      if [[ "$exceededAmount" -lt 1000 ]]; then
        batchSize=3
      else
        batchSize=$(((exceededAmount + 999) / 1000))
        [[ $batchSize -lt 1 ]] && batchSize=1
      fi

      # Keep only the remaining lines within the limit
      linesToKeep=$((totalLines - batchSize))

      pastResults=$(echo "$pastResults" | head -n "$linesToKeep")
      pastResultsTable=$(echo "$pastResults" | awk 'NR != 2' | convertToHTMLTable -d ";[|];")
      pastResultsTable=$(sed -e 's/<th>/<th><b>/g' \
      -e 's/<\/th>/<\/b><\/th>/g' \
      -e "s/<th><b>Date/<th style='width: 14em'><b>Date/g" \
      -e "s/<th><b>Down/<th style='width: 9em'><i class='fa-solid fa-circle-down'><\/i>\&nbsp;\&nbsp;<b>Down/g" \
      -e "s/<th><b>Up/<th style='width: 9em'><i class='fa-solid fa-circle-up'><\/i>\&nbsp;\&nbsp;<b>Up/g" \
      -e "s/<th><b>Jitter/<th style='width: 7em'><i class='fa-solid fa-chart-line'><\/i>\&nbsp;\&nbsp;<b>Jitter/g" \
      -e "s/<th><b>Latency/<th style='width: 7em'><i class='fa-solid fa-server'><\/i>\&nbsp;\&nbsp;<b>Latency/g" \
      -e "s/<th><b>High/<th style='width: 7em'><i class='fa-solid fa-chevron-up'><\/i>\&nbsp;\&nbsp;<b>High/g" \
      -e "s/<th><b>Low/<th style='width: 7em'><i class='fa-solid fa-chevron-down'><\/i>\&nbsp;\&nbsp;<b>Low/g" \
      <<< "$pastResultsTable")

      pastResultsCard="<div class='card flex-grow-1'>
  <div class='card-title-box'>
    <div class='card-title'><i class='fa-solid fa-book'></i>&nbsp;&nbsp;Past Results</div>
  </div>
  <div class='card-body' style='white-space: nowrap'>
    <h1>This information has been truncated to accommodate the character limit of 200,000 characters.</h1>
    $pastResultsTable
  </div>
</div>"

      wysiwygValue="<div>"
      wysiwygValue+=$'\n'$speedTestCard
      wysiwygValue+=$'\n'$pastResultsCard
      wysiwygValue+=$'\n'"<code class='d-none'>
  $pastResults
</code>"
      wysiwygValue+=$'\n'"</div>"
      characterCount=${#wysiwygValue}
    done
  fi

  # Attempt to set the WYSIWYG custom field with the formatted value
  if ! output=$("$ninjarmmcliPath" set "$_arg_wysiwygCustomField" "$wysiwygValue" 2>&1); then
    echo "[Error] $output" >&2
    ExitCode=1
  else
    echo "Successfully set the custom field '$_arg_wysiwygCustomField'."
  fi

  echo ""
fi

# Print the speed test results to the console
echo "### Speed Test Results ###"
echo "Date : $speedTestStartDate
Server : $speedTestServer
Down : $downloadSpeed Mbps
Up : $uploadSpeed Mbps
Interface : $interfaceName
MacAddress : $macAddress
Jitter : $jitter ms
Latency : $avgLatency ms
Low : $minLatency ms
High : $maxLatency ms"

# Exit the script with the appropriate exit code
exit "$exitCode"

 

Detailed Breakdown

Key implementation details:

  • Argument parsing supports:
    • --multilineCustomField|-m, --wysiwygCustomField|-w to target NinjaOne fields.
    • --append|-a to merge with existing field content.
    • --sleepBeforeRunning|-s to stagger execution.
    • Script-form variables (e.g., multilineCustomFieldName, appendToCustomField) can override CLI flags—useful in RMM parameters.
  • Environment checks: Fails fast if not root, if networkQuality isn’t present (pre-Monterey), or if ninjarmm-cli cannot be found at $NINJA_DATA_PATH/ninjarmm-cli or /Applications/NinjaRMMAgent/programdata/ninjarmm-cli.
  • Extraction & metrics:
    • Throughput values (dl_throughput, ul_throughput) are converted from bytes/sec to Mbps with two-decimal precision.
    • Latency samples (il_h2_req_resp) are aggregated to compute average latency, jitter (as standard deviation), and min/max.
    • Interface name is matched via networksetup -listallhardwareports to show Wi-Fi vs Wired and the MAC address.
  • Persisting results:
    • Multiline field: saves a concise, newline-delimited summary. If --append, prior content is fetched and prepended. A soft cap keeps content under 10,000 chars.
    • WYSIWYG field: renders a card-style report (Font Awesome icons, labeled stats) and builds a historical HTML table from a hidden <code> block. Append mode parses existing WYSIWYG HTML via plutil to rebuild the table. A safety mechanism trims content under 200,000 chars and clearly marks truncation when needed.
  • Console output: Always prints a human-readable summary (date, server, down/up, interface, MAC, jitter, average, low, high), and exits with a success/failure code that your RMM can act on.

Potential Use Cases

Case study: An MSP supports a multi-site retail chain where PoS terminals intermittently lag during checkout. The team deploys this Shell script for macOS Internet Speed Test to all store Macs:

  • Schedules it weekly with --append --wysiwygCustomField "SpeedTestHistory" --sleepBeforeRunning.
  • Over two weeks, they correlate jitter spikes and upload drops to a specific ISP window.
  • With historical tables embedded in device records, they present evidence to the ISP, who finds congestion on the upstream. SLA credits follow—and checkout delays vanish.

Comparisons

  • Commercial speed test clients: Rich dashboards, but often require agents, license management, or external storage. This script is zero-cost, uses Apple’s native tool, and stores results where your techs already work (NinjaOne).
  • Browser-based tests: Useful ad-hoc, but lack repeatability, scheduling, or standard output; headless execution is awkward.
  • iperf: Great for point-to-point diagnostics you control, but it needs a server endpoint and doesn’t reflect “real-world” internet paths the way networkQuality does.
  • Homegrown curl/dd tricks: Quick hacks, not representative, and hard to standardize across a fleet.

Implications

Speed test telemetry is more than a number. Jitter and latency variance directly affect VoIP, video calls, and interactive apps. Consistent, per-device histories help you:

  • Disambiguate root cause: Device/adapter vs. WLAN vs. WAN vs. ISP.
  • Prove performance to stakeholders and ISPs with time-bounded evidence.
  • Harden security posture indirectly: When connectivity is healthy, patch windows, EDR updates, and identity flows are more predictable. Conversely, chronic packet delay can mask or exacerbate failure modes that look like security issues (timeouts, auth retries). Storing results alongside device metadata allows faster triage and fewer misattributed incidents.

Recommendations

  • Use append + sleep in fleets: --append --sleepBeforeRunning reduces test storms and builds a usable timeline.
  • Pick one field type per audience: Multiline for quick text diffs, WYSIWYG for exec-friendly visuals and tables.
  • Standardize naming: Keep custom field names alphanumeric (as the script enforces) and consistent across policies.
  • Schedule off-peak: Run after-hours so tests don’t collide with backups or software distribution.
  • Correlate with events: Pair results with Wi-Fi channel changes, switch port errors, or ISP maintenance windows.
  • Alert on outliers: Have your RMM parse the console output or CF values to flag high jitter/latency thresholds.

Final Thoughts

Capturing speed, latency, and jitter is table stakes; operationalizing that data inside your endpoint records is where teams win back time. This Internet Speed Test macOS shell script meets that bar by marrying Apple’s networkQuality with sensible parsing and NinjaOne-native storage. Within NinjaOne, you can deploy the script at scale, parameterize custom fields, append histories, and surface rich cards to technicians and stakeholders—all from the same pane of glass you already use for patching, alerting, and remediation. That unified workflow is what turns one-off diagnostics into durable, fleet-wide insight.

FAQs

No. The script checks for networkQuality, which ships with macOS 12.0+.

Commands like networksetup and interface queries are more reliable as root, and RMM execution contexts typically run with system privileges.

Skip both custom field options. You still get a clean summary and exit code for alerting/reporting.

Yes. The script captures the interface and MAC address, and the WYSIWYG table preserves each row’s medium.

It computes standard deviation across the latency sample set exposed by networkQuality.

The script trims gracefully and clearly inserts a truncation header so techs know more history existed.

Next Steps

Building an efficient and effective IT team requires a centralized solution that acts as your core service delivery tool. NinjaOne enables IT teams to monitor, manage, secure, and support all their devices, wherever they are, without the need for complex on-premises infrastructure.

Learn more about NinjaOne Remote Script Deployment, check out a live tour, or start your free trial of the NinjaOne platform.

Categories:

You might also like